Files
Enelix-EMS/libs/ManagerNetzfahrplanV4EmpfangTrait.php
T

256 lines
17 KiB
PHP

<?php
declare(strict_types=1);
namespace Belevo\EnelixEMS;
use RuntimeException;
use Throwable;
require_once __DIR__ . '/NetzfahrplanV4Planpruefung.php';
require_once __DIR__ . '/NetzfahrplanV4Rueckmeldung.php';
/** Independent preview only: no SendData, consumer RequestAction or legacy plan writes. */
trait ManagerNetzfahrplanV4EmpfangTrait
{
private function registriereNetzfahrplanV4Empfang(): void
{
$this->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 / Prognosemanager', 'items' => [
['type' => 'Label', 'caption' => 'Der alte intelligente Netzfahrplan muss AUS bleiben. Die separate Laufzeitvariable unter dem Manager schaltet den zeitlich begrenzten Testbetrieb ein oder aus.'],
['type' => 'CheckBox', 'name' => 'NetzfahrplanV4EmpfangAktiv', 'caption' => 'V4 Schattenplan empfangen und Sollwert anzeigen (keine Steuerung)'],
['type' => 'Label', 'caption' => 'Schalter: V4 Prognosemanager aktiv (Testbetrieb). Ausschalten beendet V4 und fordert sofort 0 W an. Maximale Laufzeit ab dem ersten Einschalten: 48 Stunden.'],
['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', 'controlledTrial'];
$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) {
$controllers=[];
foreach ($this->aktiveVerbraucherIDs() as $controller) {
if (IPS_InstanceExists($controller)
&& IPS_GetInstance($controller)['ModuleInfo']['ModuleID']==='{437FB683-517F-4FEC-8CCB-FE6B0A62B69E}'
&& IPS_GetProperty($controller,'IstleistungVariableID')===($asset['LeistungVariableID']??null)
&& IPS_GetProperty($controller,'LadezustandVariableID')===($asset['SOCVariableID']??null)) $controllers[]=$controller;
}
if (count($controllers)>1) throw new RuntimeException('feedback_controller_ambiguous');
if (count($controllers)===1 && function_exists('ENELIX_GetV4BatterieRueckmeldung')) {
$controller=$controllers[0];$configured=IPS_GetProperty($controller,'NetzfahrplanV4RueckmeldungKonfiguration');
if (is_string($configured) && !in_array(trim($configured),['','{}'],true)) {
if (count($topology)!==1) throw new RuntimeException('feedback_multi_asset_adapter_required');
$f=json_decode(ENELIX_GetV4BatterieRueckmeldung($controller),true,32,JSON_THROW_ON_ERROR);
if (($f['status']??null)==='unavailable') {
$reason=is_string($f['reason']??null)?$f['reason']:'unknown';
throw new RuntimeException('feedback_unavailable:'.substr($reason,0,180));
}
if (($f['installationId']??null)!==$this->ReadAttributeString('LizenzInstallationID')
|| ($f['assetId']??null)!==$asset['ID'] || ($f['managerId']??null)!==$this->InstanceID
|| ($f['batteryInstanceId']??null)!==$controller || !is_numeric($f['batteryW']??null)
|| !is_numeric($f['gridW']??null) || !is_int($f['checkedAt']??null) || $now-$f['checkedAt']>2 || $f['checkedAt']>$now) {
throw new RuntimeException('feedback_invalid_no_legacy_fallback');
}
$actual[$asset['ID']]=$f['batteryW'];$d['operation']['gridW']=$f['gridW'];
$d['operation']['localFeedback'][$asset['ID']]=$f;
continue;
}
}
// Legacy value remains display-only until an explicit physical feedback mapping exists.
$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['localFeedback'] = $operation['localFeedback'] ?? [];
$out['feedbackMeaning'] = isset($operation['localFeedback']) ? 'fresh_physical_or_explicit_partition_model' : 'legacy_display_only_not_for_trial';
$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 = '<h3>V4: NUR VORSCHAU - KEINE STELLFREIGABE</h3><table>';
foreach (['status' => 'Status', 'planId' => 'Plan-ID', 'family' => 'Modellfamilie', 'step' => 'Zeitschritt (UTC)',
'stepValidUntil' => 'Gueltig bis', 'gridTargetW' => 'Geplantes Netzziel W', 'plannedBatteryW' => 'Geplante Batterie W',
'previewBatteryW' => 'Lokal berechnete Batterie W (nicht ausgegeben)', 'reason' => 'Ablehnungsgrund'] as $key => $label) {
$item = $value[$key] ?? '-';if (is_float($item)) $item = number_format($item, 1, '.', "'");
$html .= '<tr><th>' . $escape($label) . '</th><td>' . $escape($item) . '</td></tr>';
}
$html .= '</table><p>Batterie + = Laden, - = Entladen. Netz + = Bezug, - = Einspeisung.</p>';
foreach ($value['reasons'] ?? [] as $reason) $html .= '<p>' . $escape($reason) . '</p>';
$html .= '<p>Empfangsbestaetigung ist keine Ausfuehrungsbestaetigung.</p>';
$this->SetValue('NetzfahrplanV4VorschauJSON', json_encode($value, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES));
$this->SetValue('NetzfahrplanV4Vorschau', $html);
}
}