RegisterPropertyBoolean('NetzfahrplanV4EmpfangAktiv', false); $this->RegisterAttributeString('NetzfahrplanV4Empfang', '{}'); $this->RegisterAttributeString('NetzfahrplanV4EmpfangStatus', '{}'); $this->RegisterAttributeString('NetzfahrplanV4LetzteBestaetigung', '{}'); $this->RegisterAttributeInteger('NetzfahrplanV4NaechsterAbruf', 0); $this->RegisterVariableString('NetzfahrplanV4Vorschau', 'V4 Sollwert-Vorschau (KEINE Stellfreigabe)', '~HTMLBox', 950); $this->RegisterVariableString('NetzfahrplanV4VorschauJSON', 'V4 Vorschau Diagnose (nur lesen)', '', 951); $this->RegisterTimer('NetzfahrplanV4VorschauAktualisieren', 0, "IPS_RequestAction(\$_IPS['TARGET'], 'NetzfahrplanV4VorschauAktualisieren', true);"); } private function stoppeNetzfahrplanV4Vorschau(): void { $this->SetTimerInterval('NetzfahrplanV4VorschauAktualisieren', 0); $this->WriteAttributeString('NetzfahrplanV4Empfang', '{}'); $this->WriteAttributeString('NetzfahrplanV4LetzteBestaetigung', '{}'); $this->WriteAttributeString('NetzfahrplanV4EmpfangStatus', '{"status":"not_received"}'); $this->zeigeNetzfahrplanV4Vorschau(['status' => 'not_received', 'reason' => 'Neu initialisiert; keinen alten Sollwert weiterverwenden.']); } private function konfiguriereNetzfahrplanV4Vorschau(): void { $enabled = $this->ReadPropertyBoolean('NetzfahrplanV4EmpfangAktiv') && $this->ReadPropertyBoolean('NetzfahrplanV4SchattenAktiv') && !$this->ReadPropertyBoolean('NetzfahrplanAktiv'); $this->SetTimerInterval('NetzfahrplanV4VorschauAktualisieren', $enabled ? 10000 : 0); $this->aktualisiereNetzfahrplanV4Vorschau(); } /** Appended dynamically; existing form.json and parallel portal GUI stay untouched. */ private function netzfahrplanV4EmpfangFormular(): array { return ['type' => 'ExpansionPanel', 'caption' => 'V4 Planempfang / Sollwert-Vorschau', 'items' => [ ['type' => 'Label', 'caption' => 'NUR BEOBACHTEN. Kein Lade-/Entladebefehl. Der alte intelligente Netzfahrplan muss AUS bleiben.'], ['type' => 'CheckBox', 'name' => 'NetzfahrplanV4EmpfangAktiv', 'caption' => 'V4 Schattenplan empfangen und Sollwert anzeigen (keine Steuerung)'], ['type' => 'Label', 'caption' => 'Diagnose und Sollwerte stehen in den beiden V4-Vorschauvariablen unterhalb des Managers.'], ['type' => 'Button', 'caption' => 'Plan jetzt pruefen (Abruf begrenzt auf 1/min)', 'onClick' => "IPS_RequestAction(\$id, 'NetzfahrplanV4Empfangen', true);"], ]]; } /** Called before the next operation upload, so the preceding completed plan can be seen. */ private function empfangeNetzfahrplanV4(): void { if (!$this->ReadPropertyBoolean('NetzfahrplanV4EmpfangAktiv')) return; $lock = 'ENELIX.V4.Receive.' . $this->InstanceID; if (!IPS_SemaphoreEnter($lock, 0)) return; try { $now = time(); if ($now < $this->ReadAttributeInteger('NetzfahrplanV4NaechsterAbruf')) return; $this->WriteAttributeInteger('NetzfahrplanV4NaechsterAbruf', $now + 60); if (!$this->ReadPropertyBoolean('NetzfahrplanV4SchattenAktiv') || $this->ReadPropertyBoolean('NetzfahrplanAktiv')) { throw new RuntimeException('Schatten-Sender fehlt oder alter Fahrplan ist eingeschaltet.'); } if (!$this->berechtigungLizenziert(Lizenzpruefung::NETZFAHRPLAN)) { throw new RuntimeException('Netzfahrplanberechtigung fehlt.'); } $response = $this->netzfahrplanV4EmpfangHttp('GET', ''); $receipt = time(); if (!$this->ReadPropertyBoolean('NetzfahrplanV4EmpfangAktiv') || $this->ReadPropertyBoolean('NetzfahrplanAktiv')) { throw new RuntimeException('Freigabe waehrend des Abrufs geaendert.'); } // Clear the old envelope before validation: invalid replies never extend its lifetime. $this->WriteAttributeString('NetzfahrplanV4Empfang', '{}'); [$operation, $actual] = $this->netzfahrplanV4LokaleVorschauwerte(); $checked = NetzfahrplanV4Planpruefung::pruefen($response, $this->ReadAttributeString('LizenzInstallationID'), $operation, time(), $receipt); $preview = NetzfahrplanV4Planpruefung::vorschau($checked, $operation, $actual, time()); // Only the known, non-secret protocol envelope is cached. $keys = ['receiverProtocolVersion', 'installationId', 'checkedAt', 'liveEnabled', 'fresh', 'pending', 'settings', 'plan', 'families', 'lastRun']; $safe = array_intersect_key($response, array_flip($keys)); $this->WriteAttributeString('NetzfahrplanV4Empfang', json_encode( ['receivedAt' => $receipt, 'response' => $safe], JSON_THROW_ON_ERROR)); $state = ['status' => 'received_shadow', 'at' => gmdate('c', $receipt), 'acknowledged' => false]; $ack = ['planId' => $preview['planId'], 'revision' => $preview['revision'], 'step' => $preview['step'], 'status' => 'shadow_seen']; $last = json_decode($this->ReadAttributeString('NetzfahrplanV4LetzteBestaetigung'), true); if ($last !== $ack) { try { $answer = $this->netzfahrplanV4EmpfangHttp('POST', '/ack', $ack); if (($answer['status'] ?? null) !== 'recorded') throw new RuntimeException('Empfangsbestaetigung abgelehnt.'); $this->WriteAttributeString('NetzfahrplanV4LetzteBestaetigung', json_encode($ack, JSON_THROW_ON_ERROR)); $state['acknowledged'] = true; } catch (Throwable $error) { $state['acknowledgementError'] = substr($error->getMessage(), 0, 200); } } else { $state['acknowledged'] = true; } $this->WriteAttributeString('NetzfahrplanV4EmpfangStatus', json_encode($state, JSON_THROW_ON_ERROR)); $this->aktualisiereNetzfahrplanV4Vorschau(); } catch (Throwable $error) { $this->WriteAttributeString('NetzfahrplanV4Empfang', '{}'); $state = ['status' => 'rejected_or_unavailable', 'at' => gmdate('c'), 'reason' => substr($error->getMessage(), 0, 300)]; $this->WriteAttributeString('NetzfahrplanV4EmpfangStatus', json_encode($state, JSON_THROW_ON_ERROR)); $this->zeigeNetzfahrplanV4Vorschau($state); } finally { IPS_SemaphoreLeave($lock); } } /** Fixed authenticated read/ack endpoints only; never operation, settings, replan or dispatch. */ private function netzfahrplanV4EmpfangHttp(string $method, string $suffix, ?array $body = null): array { if (!(($method === 'GET' && $suffix === '' && $body === null) || ($method === 'POST' && $suffix === '/ack' && ($body['status'] ?? null) === 'shadow_seen'))) { throw new RuntimeException('Unzulaessiger V4-Empfangsaufruf.'); } $id = $this->ReadAttributeString('LizenzInstallationID'); $token = $this->ReadAttributeString('PrognoseInstallationsToken'); if (!preg_match('/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/D', $id) || !preg_match('/^[A-Za-z0-9_-]{43,128}$/D', $token)) { throw new RuntimeException('Installations-ID oder bestehender Geraetezugang fehlt.'); } $handle = curl_init('https://license.enelix.ch/api/v1/installations/' . rawurlencode($id) . '/prognosis/planner-v4' . $suffix); if ($handle === false) throw new RuntimeException('V4-Empfang konnte nicht vorbereitet werden.'); $response = ''; try { $options = [CURLOPT_CUSTOMREQUEST => $method, CURLOPT_FOLLOWLOCATION => false, CURLOPT_CONNECTTIMEOUT => 2, CURLOPT_TIMEOUT => 5, CURLOPT_SSL_VERIFYPEER => true, CURLOPT_SSL_VERIFYHOST => 2, CURLOPT_PROTOCOLS => CURLPROTO_HTTPS, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $token, 'Accept: application/json', 'Content-Type: application/json', 'Cache-Control: no-cache'], CURLOPT_WRITEFUNCTION => static function ($curl, string $chunk) use (&$response): int { if (strlen($response) + strlen($chunk) > NetzfahrplanV4Planpruefung::MAX_RESPONSE_BYTES) return 0; $response .= $chunk;return strlen($chunk); }]; if ($body !== null) $options[CURLOPT_POSTFIELDS] = json_encode($body, JSON_THROW_ON_ERROR); curl_setopt_array($handle, $options); $ok = curl_exec($handle);$code = (int) curl_getinfo($handle, CURLINFO_HTTP_CODE); if ($ok === false || $code !== 200) throw new RuntimeException('V4-Planempfang HTTP ' . $code . '; kein gueltiger Vorschauwert.'); return NetzfahrplanV4Planpruefung::dekodieren($response); } finally { curl_close($handle); } } /** Reads independently of the old control loop. Never manufactures fresh timestamps. */ private function netzfahrplanV4LokaleVorschauwerte(): array { $d = json_decode($this->GetNetzfahrplanV4Diagnose(), true, 64, JSON_THROW_ON_ERROR); if (!is_array($d['operation'] ?? null)) throw new RuntimeException('Lokale Betriebsdaten ungueltig; Vorschau gesperrt.'); $topology = json_decode($this->ReadPropertyString('AnlagenBatterien'), true, 64, JSON_THROW_ON_ERROR); $actual = [];$now = time(); foreach ($topology as $asset) { $id = (int) ($asset['LeistungVariableID'] ?? 0); if (!IPS_VariableExists($id)) throw new RuntimeException('Batterie-Istleistung fehlt.'); $before = IPS_GetVariable($id);$v = GetValue($id);$after = IPS_GetVariable($id); $factor = $asset['Leistungsfaktor'] ?? null; if ((!is_int($v) && !is_float($v)) || !is_finite((float) $v) || (!is_int($factor) && !is_float($factor)) || !is_finite((float) $factor) || $factor == 0 || $before['VariableUpdated'] !== $after['VariableUpdated'] || $after['VariableUpdated'] > $now || $now - $after['VariableUpdated'] > $this->ReadPropertyInteger('MesswertMaxAlter')) { throw new RuntimeException('Batterie-Istleistung fehlt, ist veraltet oder inkonsistent.'); } $actual[$asset['ID']] = (float) $v * $factor; } return [$d['operation'], $actual]; } public function GetNetzfahrplanV4Vorschau(): string { try { if (!$this->ReadPropertyBoolean('NetzfahrplanV4EmpfangAktiv') || !$this->ReadPropertyBoolean('NetzfahrplanV4SchattenAktiv')) { throw new RuntimeException('V4-Empfang ausgeschaltet.'); } if ($this->ReadPropertyBoolean('NetzfahrplanAktiv')) throw new RuntimeException('Alter Fahrplan ist aktiv; V4-Vorschau gesperrt.'); if (!$this->berechtigungLizenziert(Lizenzpruefung::NETZFAHRPLAN)) throw new RuntimeException('Netzfahrplanberechtigung fehlt.'); $envelope = json_decode($this->ReadAttributeString('NetzfahrplanV4Empfang'), true, 64, JSON_THROW_ON_ERROR); if (!is_array($envelope['response'] ?? null) || !is_int($envelope['receivedAt'] ?? null)) { throw new RuntimeException('Noch kein gueltiger V4-Plan empfangen.'); } [$operation, $actual] = $this->netzfahrplanV4LokaleVorschauwerte(); $checked = NetzfahrplanV4Planpruefung::pruefen($envelope['response'], $this->ReadAttributeString('LizenzInstallationID'), $operation, time(), $envelope['receivedAt']); $out = NetzfahrplanV4Planpruefung::vorschau($checked, $operation, $actual, time()); $out['reception'] = json_decode($this->ReadAttributeString('NetzfahrplanV4EmpfangStatus'), true); } catch (Throwable $error) { $out = ['status' => 'unavailable', 'reason' => substr($error->getMessage(), 0, 300)]; } return json_encode(array_merge(['gridTargetW' => null, 'plannedBatteryW' => null, 'previewBatteryW' => null], $out, ['canDispatch' => false, 'liveEnabled' => false]), JSON_THROW_ON_ERROR); } private function aktualisiereNetzfahrplanV4Vorschau(): void { $this->zeigeNetzfahrplanV4Vorschau(json_decode($this->GetNetzfahrplanV4Vorschau(), true, 64, JSON_THROW_ON_ERROR)); } private function zeigeNetzfahrplanV4Vorschau(array $value): void { $value = array_merge(['gridTargetW' => null, 'plannedBatteryW' => null, 'previewBatteryW' => null], $value, ['canDispatch' => false, 'liveEnabled' => false]); $escape = static fn($v): string => htmlspecialchars((string) $v, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); $html = '
| ' . $escape($label) . ' | ' . $escape($item) . ' |
|---|
Batterie + = Laden, - = Entladen. Netz + = Bezug, - = Einspeisung.
'; foreach ($value['reasons'] ?? [] as $reason) $html .= '' . $escape($reason) . '
'; $html .= 'Empfangsbestaetigung ist keine Ausfuehrungsbestaetigung.
'; $this->SetValue('NetzfahrplanV4VorschauJSON', json_encode($value, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES)); $this->SetValue('NetzfahrplanV4Vorschau', $html); } }