diff --git a/Verbrauchskostenreport/form.json b/Verbrauchskostenreport/form.json index 1f5a937..1949494 100644 --- a/Verbrauchskostenreport/form.json +++ b/Verbrauchskostenreport/form.json @@ -1,5 +1,41 @@ { "elements": [ + { + "type": "ExpansionPanel", + "caption": "Lizenzierung", + "expanded": true, + "items": [ + { + "type": "ValidationTextBox", + "name": "Lizenzcode", + "caption": "Verbrauchskostenlizenzcode", + "placeholder": "ENX-XXXX-XXXX-XXXX-XXXX" + }, + { + "type": "Label", + "name": "LizenzInstallationID", + "caption": "Installations-ID wird erzeugt" + }, + { + "type": "Label", + "name": "LizenzInformation", + "caption": "Verbrauchskostenlizenzcode fehlt." + }, + { + "type": "Button", + "name": "LizenzPruefen", + "caption": "Lizenz prüfen und binden", + "onClick": "IPS_RequestAction($id, 'FormLizenzPruefen', $Lizenzcode);" + }, + { + "type": "Button", + "name": "LizenzVerwalten", + "caption": "Lizenz verwalten", + "link": true, + "onClick": "echo 'https://license.enelix.ch';" + } + ] + }, { "type": "Label", "caption": "🧾 Einstellungen Verbrauchskostenreport" diff --git a/Verbrauchskostenreport/module.php b/Verbrauchskostenreport/module.php index e3e377f..f29b1a4 100644 --- a/Verbrauchskostenreport/module.php +++ b/Verbrauchskostenreport/module.php @@ -36,6 +36,12 @@ class VerbrauchskostenreportPDF extends TCPDF class Verbrauchskostenreport extends IPSModule { + private const LIZENZ_ENDPOINT = 'https://license.enelix.ch/api/v1/licenses/activate'; + private const LIZENZ_RETRY_SEKUNDEN = 900; + private const KATALOG_GRUNDLIZENZ = 'cost_report'; + private const KATALOG_STROMZAEHLER = 'cost_meter_power'; + private const KATALOG_NEBENZAEHLER = 'cost_meter_aux'; + /** @var array>> */ private array $powerCostCache = []; @@ -47,6 +53,7 @@ class Verbrauchskostenreport extends IPSModule $this->RegisterPropertyString('PowerMeters', '[]'); $this->RegisterPropertyString('WaterMeters', '[]'); $this->RegisterPropertyString('Tariffs', '[]'); + $this->RegisterPropertyString('Lizenzcode', ''); $this->RegisterPropertyInteger('LogoMediaID', 0); $this->RegisterPropertyString('PropertyText', 'Liegenschaft'); $this->RegisterPropertyString('FooterText', 'Belevo AG • 6122 Menznau • www.belevo.ch'); @@ -64,6 +71,15 @@ class Verbrauchskostenreport extends IPSModule $this->RegisterPropertyString('CreditorCountry', 'CH'); $this->RegisterPropertyString('BankIBAN', ''); + $this->RegisterAttributeString('LizenzInstallationID', ''); + $this->RegisterAttributeString('LizenzLease', '{}'); + $this->RegisterAttributeString('LizenzcodeHash', ''); + $this->RegisterAttributeString('LizenzLetzterFehler', ''); + $this->RegisterAttributeInteger('LizenzNaechsterVersuch', 0); + if ($this->ReadAttributeString('LizenzInstallationID') === '') { + $this->WriteAttributeString('LizenzInstallationID', $this->ErzeugeUuidV4()); + } + $this->RegisterTariffVariableProfiles(); $this->RegisterVariableInteger('FromDate', 'Startdatum', '~UnixTimestamp', 1); @@ -106,6 +122,8 @@ class Verbrauchskostenreport extends IPSModule $this->InitializeVisuTariffDefaults(); $this->UpdateTariffOverview(); $this->UpdateUserOverview(); + $this->AktualisiereLizenz($this->ReadPropertyString('Lizenzcode'), false); + $this->AktualisiereLizenzForm(); } public function Destroy() @@ -630,6 +648,278 @@ form.onsubmit=function(){ return $this->SaveTariffTableFromPayload(json_encode($rows)); } + private function AktualisiereLizenz(string $lizenzcode, bool $erzwingen): bool + { + $code = $this->NormalisiereLizenzcode($lizenzcode); + if (!$this->IstLizenzcodeGueltig($code)) { + $this->WriteAttributeString( + 'LizenzLetzterFehler', + $code === '' ? 'Verbrauchskostenlizenzcode fehlt.' : 'Verbrauchskostenlizenzcode ist ungültig.' + ); + return false; + } + + $installationID = $this->ReadAttributeString('LizenzInstallationID'); + if ($installationID === '') { + $installationID = $this->ErzeugeUuidV4(); + $this->WriteAttributeString('LizenzInstallationID', $installationID); + } + + $codeHash = hash('sha256', $code); + $gespeicherterHash = $this->ReadAttributeString('LizenzcodeHash'); + $lease = $this->LeseLizenzLease(); + $lokalGueltig = false; + if ($gespeicherterHash !== '' && hash_equals($gespeicherterHash, $codeHash)) { + try { + $this->PruefeLizenzLease($lease, $installationID); + $lokalGueltig = true; + } catch (Throwable $fehler) { + $lokalGueltig = false; + } + } + + $erneuernAb = strtotime((string)($lease['refreshAfter'] ?? '')) ?: 0; + if (!$erzwingen && $lokalGueltig && time() < $erneuernAb) { + return true; + } + if (!$erzwingen && time() < $this->ReadAttributeInteger('LizenzNaechsterVersuch')) { + return $lokalGueltig; + } + + $this->WriteAttributeInteger( + 'LizenzNaechsterVersuch', + time() + self::LIZENZ_RETRY_SEKUNDEN + ); + try { + $neueLease = $this->RufeLizenzserverAuf($code, $installationID); + $this->PruefeLizenzLease($neueLease, $installationID); + $this->WriteAttributeString( + 'LizenzLease', + json_encode($neueLease, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES) + ); + $this->WriteAttributeString('LizenzcodeHash', $codeHash); + $this->WriteAttributeString('LizenzLetzterFehler', ''); + $this->WriteAttributeInteger( + 'LizenzNaechsterVersuch', + strtotime((string)$neueLease['refreshAfter']) ?: time() + 86400 + ); + return true; + } catch (Throwable $fehler) { + $this->WriteAttributeString( + 'LizenzLetzterFehler', + substr($fehler->getMessage(), 0, 500) + ); + return $lokalGueltig; + } + } + + private function AktualisiereLizenzForm(?string $lizenzcode = null): void + { + $this->UpdateFormField( + 'LizenzInstallationID', + 'caption', + 'Installations-ID: ' . $this->ReadAttributeString('LizenzInstallationID') + ); + $this->UpdateFormField( + 'LizenzInformation', + 'caption', + $this->LizenzStatustext($lizenzcode) + ); + } + + private function LizenzStatustext(?string $lizenzcode = null): string + { + $code = $this->NormalisiereLizenzcode( + $lizenzcode ?? $this->ReadPropertyString('Lizenzcode') + ); + if ($code === '') { + return 'Verbrauchskostenlizenzcode fehlt.'; + } + if (!$this->IstLizenzcodeGueltig($code)) { + return 'Verbrauchskostenlizenzcode ist ungültig.'; + } + + $hash = $this->ReadAttributeString('LizenzcodeHash'); + $fehler = $this->ReadAttributeString('LizenzLetzterFehler'); + if ($hash === '' || !hash_equals($hash, hash('sha256', $code))) { + return $fehler !== '' ? 'Nicht freigegeben: ' . $fehler : 'Lizenz noch nicht bestätigt.'; + } + + try { + $lease = $this->LeseLizenzLease(); + $this->PruefeLizenzLease( + $lease, + $this->ReadAttributeString('LizenzInstallationID') + ); + $status = sprintf( + 'Aktiv: %d Stromzähler und %d Wärme-/Wasserzähler lizenziert.', + $this->LizenzMenge($lease, self::KATALOG_STROMZAEHLER), + $this->LizenzMenge($lease, self::KATALOG_NEBENZAEHLER) + ); + if ($fehler !== '') { + $status .= ' Online-Prüfung fehlgeschlagen; lokale Freigabe ist noch gültig. Fehler: ' + . $fehler; + } + return $status; + } catch (Throwable $ungueltig) { + return 'Nicht freigegeben: ' . $ungueltig->getMessage(); + } + } + + private function PruefeLizenzLease(array $lease, string $installationID): void + { + if (($lease['status'] ?? null) !== 'active') { + throw new RuntimeException('Die Verbrauchskostenlizenz ist nicht aktiv.'); + } + if (($lease['installationId'] ?? null) !== $installationID) { + throw new RuntimeException('Die Verbrauchskostenlizenz gehört zu einer anderen Installation.'); + } + $offlineBis = strtotime((string)($lease['offlineUntil'] ?? '')); + if ($offlineBis === false || time() >= $offlineBis) { + throw new RuntimeException('Die Offline-Freigabe ist abgelaufen.'); + } + if ($this->LizenzMenge($lease, self::KATALOG_GRUNDLIZENZ) < 1) { + throw new RuntimeException('Die Grundlizenz für den Verbrauchskostenreport fehlt.'); + } + + $strom = count($this->LeseJsonListe('PowerMeters')); + $neben = count($this->LeseJsonListe('WaterMeters')); + $stromLizenziert = $this->LizenzMenge($lease, self::KATALOG_STROMZAEHLER); + $nebenLizenziert = $this->LizenzMenge($lease, self::KATALOG_NEBENZAEHLER); + if ($strom > $stromLizenziert) { + throw new RuntimeException(sprintf( + 'Stromzähler: %d konfiguriert, aber nur %d lizenziert.', + $strom, + $stromLizenziert + )); + } + if ($neben > $nebenLizenziert) { + throw new RuntimeException(sprintf( + 'Wärme-/Wasserzähler: %d konfiguriert, aber nur %d lizenziert.', + $neben, + $nebenLizenziert + )); + } + } + + private function LizenzMenge(array $lease, string $kennung): int + { + $menge = 0; + foreach (($lease['entitlements'] ?? []) as $berechtigung) { + if (!is_array($berechtigung) || ($berechtigung['status'] ?? null) !== 'active') { + continue; + } + $schluessel = $berechtigung['catalog_key'] ?? $berechtigung['sku'] ?? null; + $anzahl = $berechtigung['quantity'] ?? 0; + if ($schluessel === $kennung && is_int($anzahl) && $anzahl > 0) { + $menge += $anzahl; + } + } + return $menge; + } + + private function LeseJsonListe(string $property): array + { + try { + $werte = json_decode( + $this->ReadPropertyString($property), + true, + 512, + JSON_THROW_ON_ERROR + ); + return is_array($werte) ? array_values($werte) : []; + } catch (Throwable $fehler) { + return []; + } + } + + 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 RufeLizenzserverAuf(string $lizenzcode, string $installationID): array + { + $curl = curl_init(self::LIZENZ_ENDPOINT); + if ($curl === false) { + throw new RuntimeException('Die HTTPS-Anfrage konnte nicht vorbereitet werden.'); + } + $nutzlast = json_encode([ + 'code' => $lizenzcode, + 'installationId' => $installationID, + ], JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES); + + 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-Verbrauchskostenreport/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); + } + + $daten = json_decode($antwort, true, 512, JSON_THROW_ON_ERROR); + if (!is_array($daten)) { + throw new RuntimeException('Der Lizenzserver lieferte kein JSON-Objekt.'); + } + if ($httpStatus !== 200) { + throw new RuntimeException( + 'Lizenzserver HTTP ' . $httpStatus . ': ' + . (is_string($daten['error'] ?? null) ? $daten['error'] : 'Unbekannter Fehler') + ); + } + return $daten; + } + + private function NormalisiereLizenzcode(string $code): string + { + return strtoupper((string)preg_replace('/[^A-Z0-9]/i', '', $code)); + } + + private function IstLizenzcodeGueltig(string $code): bool + { + return preg_match('/^ENX[A-HJ-NP-Z2-9]{16}$/', $code) === 1; + } + + 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 ReadUsers() { $users = json_decode($this->ReadPropertyString('Users'), true); @@ -1348,6 +1638,14 @@ refreshPickers(); public function RequestAction($Ident, $Value) { switch ($Ident) { + case 'FormLizenzPruefen': + if (!is_string($Value)) { + throw new InvalidArgumentException('Der Lizenzcode muss als Text übergeben werden.'); + } + $this->AktualisiereLizenz($Value, true); + $this->AktualisiereLizenzForm($Value); + break; + case 'FromDate': case 'ToDate': case 'VisuTariffType': @@ -1458,6 +1756,10 @@ refreshPickers(); public function GenerateReport() { + if (!$this->AktualisiereLizenz($this->ReadPropertyString('Lizenzcode'), false)) { + throw new RuntimeException($this->LizenzStatustext()); + } + $from = GetValue($this->GetIDForIdent('FromDate')); $to = GetValue($this->GetIDForIdent('ToDate'));