From b5c1fa6be2a5dd3dad66b35f6ddaddbcfcb6055f Mon Sep 17 00:00:00 2001 From: ENELIX Agent Date: Fri, 2 Oct 2026 13:01:04 +0000 Subject: [PATCH] feat(manager): receive and preview V4 shadow plans without dispatch --- Manager/module.php | 11 + docs/netplan-v4-receiver.md | 48 ++++ libs/ManagerNetzfahrplanV4EmpfangTrait.php | 223 +++++++++++++++ libs/ManagerNetzfahrplanV4Trait.php | 6 + libs/NetzfahrplanV4Planpruefung.php | 310 +++++++++++++++++++++ tests/NetzfahrplanV4EmpfangTest.php | 29 ++ tests/V4Receiver/fixture.php | 30 ++ tests/V4Receiver/protocol_checks.php | 73 +++++ tests/V4Receiver/receiver_checks.php | 117 ++++++++ 9 files changed, 847 insertions(+) create mode 100644 docs/netplan-v4-receiver.md create mode 100644 libs/ManagerNetzfahrplanV4EmpfangTrait.php create mode 100644 libs/NetzfahrplanV4Planpruefung.php create mode 100644 tests/NetzfahrplanV4EmpfangTest.php create mode 100644 tests/V4Receiver/fixture.php create mode 100644 tests/V4Receiver/protocol_checks.php create mode 100644 tests/V4Receiver/receiver_checks.php diff --git a/Manager/module.php b/Manager/module.php index ac74605..f4b2158 100644 --- a/Manager/module.php +++ b/Manager/module.php @@ -237,6 +237,7 @@ class Manager extends IPSModule implements ManagerSchnittstelle { parent::ApplyChanges(); $this->SetTimerInterval('NetzfahrplanV4Senden', 0); + $this->stoppeNetzfahrplanV4Vorschau(); $this->initialisiereLizenzInstallationID(); $this->aktualisiereVariablen(); @@ -295,6 +296,7 @@ class Manager extends IPSModule implements ManagerSchnittstelle if ($this->ReadPropertyBoolean('NetzfahrplanV4SchattenAktiv')) { $this->SetTimerInterval('NetzfahrplanV4Senden', 60000); } + $this->konfiguriereNetzfahrplanV4Vorschau(); $this->synchronisiereAnlagentopologie(); if ($this->prognoseTelemetrieKonfiguriert()) { $this->SetTimerInterval( @@ -424,6 +426,7 @@ class Manager extends IPSModule implements ManagerSchnittstelle $this->monatsgrenzenFuerFormular() ); + $formular['elements'][] = $this->netzfahrplanV4EmpfangFormular(); return json_encode($formular, JSON_THROW_ON_ERROR); } @@ -457,6 +460,14 @@ class Manager extends IPSModule implements ManagerSchnittstelle $this->regeln((bool) $wert); return; + case 'NetzfahrplanV4Empfangen': + $this->empfangeNetzfahrplanV4(); + return; + + case 'NetzfahrplanV4VorschauAktualisieren': + $this->aktualisiereNetzfahrplanV4Vorschau(); + return; + case 'NetzfahrplanV4Senden': $this->sendeNetzfahrplanV4(); return; diff --git a/docs/netplan-v4-receiver.md b/docs/netplan-v4-receiver.md new file mode 100644 index 0000000..387e383 --- /dev/null +++ b/docs/netplan-v4-receiver.md @@ -0,0 +1,48 @@ +# V4 plan reception and local preview (observation only) + +This module does not execute a battery command. The existing regulator and its +setpoints remain unchanged. `NetzfahrplanAktiv` must remain false. + +The optional `NetzfahrplanV4EmpfangAktiv` property defaults to false. When enabled +with the existing shadow sender, one bounded GET is made before the next native +operation POST, avoiding reading the plan immediately after queuing its replan. +A successful inspection acknowledges `shadow_seen` once per plan/step. This is +not `applied`. An HTTP failure clears the local preview; it does not change the +old control mode or its connection credentials. + +The V4 service must first support `receiverProtocolVersion=1`, installation IDs +in envelope and plan, per-input event references, and the stable controlContext +used at calculation time. Old or cross-plant responses are deliberately refused. +The client checks fresh/lastRun/pending status, exact configuration revision, +model-family registry, known price coverage, contiguous half-open five-minute +intervals, asset mapping and power balance. Numerical feasibility checks are +not evidence of economic performance or permission to control devices. + +A cache watchdog refreshes the diagnostic display every 10 seconds, without any +network requests. Cached response older than 90 seconds, plan older than 900 +seconds, an expired step, invalid local measurement or policy change invalidates +the preview. Missing targets are JSON null and '-' in the display, never zero. +Restart/ApplyChanges clears the cache. Existing control functions are not edited. + +The preview is currently limited to ONE battery. Multiple batteries are still +accepted by the server planner; local allocation is explicitly unsupported in +this stage. Preview feedback uses actual grid and battery power, local capacity, +available power, minimum/maximum SOC, discharge hysteresis, network-charging +permission and import/export/month limits. It exposes any remaining grid-limit +violation instead of pretending the battery can resolve it. It is a momentary +calculation only, NOT the commissioned real-time regulator. PV curtailment and +external SDL/base-load separation must still be validated before any live test. + +Two read-only variables below the manager expose a HTML summary and detailed +JSON. No EnableAction, consumer RequestAction, SendData or actor property writes +are used. The existing form.json and web portal assets are not modified. + +Offline checks: + + php tests/V4Receiver/protocol_checks.php + php tests/V4Receiver/receiver_checks.php + +The PHPUnit wrapper runs these in separate subprocesses, so fake IPS and curl +functions cannot contaminate other module tests. The mocked transport uses no +real credentials or connections. Full Symcon kernel testing and installation +are separate commissioning steps; these unit tests are not field acceptance. diff --git a/libs/ManagerNetzfahrplanV4EmpfangTrait.php b/libs/ManagerNetzfahrplanV4EmpfangTrait.php new file mode 100644 index 0000000..569a7ad --- /dev/null +++ b/libs/ManagerNetzfahrplanV4EmpfangTrait.php @@ -0,0 +1,223 @@ +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 = '

V4: NUR VORSCHAU - KEINE STELLFREIGABE

'; + 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 .= ''; + } + $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); + } +} diff --git a/libs/ManagerNetzfahrplanV4Trait.php b/libs/ManagerNetzfahrplanV4Trait.php index c6e7412..1d32ca6 100644 --- a/libs/ManagerNetzfahrplanV4Trait.php +++ b/libs/ManagerNetzfahrplanV4Trait.php @@ -7,11 +7,15 @@ namespace Belevo\EnelixEMS; use RuntimeException; use Throwable; +require_once __DIR__ . '/ManagerNetzfahrplanV4EmpfangTrait.php'; + /** Shadow telemetry only. Does not read/apply a V4 schedule or write actuators. */ trait ManagerNetzfahrplanV4Trait { + use ManagerNetzfahrplanV4EmpfangTrait; private function registriereNetzfahrplanV4(): void { + $this->registriereNetzfahrplanV4Empfang(); $this->RegisterPropertyBoolean('NetzfahrplanV4SchattenAktiv', false); $this->RegisterPropertyBoolean('NetzfahrplanV4NetzladenErlaubt', false); $this->RegisterPropertyString('NetzfahrplanV4BatterieOptionen', '{}'); @@ -107,6 +111,8 @@ trait ManagerNetzfahrplanV4Trait $this->WriteAttributeString('NetzfahrplanV4Sendestatus', '{"status":"disabled"}'); return; } + // Read the preceding completed plan BEFORE sending a new operation/replan trigger. + $this->empfangeNetzfahrplanV4(); try { if (!$this->berechtigungLizenziert(Lizenzpruefung::NETZFAHRPLAN)) { throw new RuntimeException('Netzfahrplanberechtigung fehlt.'); diff --git a/libs/NetzfahrplanV4Planpruefung.php b/libs/NetzfahrplanV4Planpruefung.php new file mode 100644 index 0000000..3138201 --- /dev/null +++ b/libs/NetzfahrplanV4Planpruefung.php @@ -0,0 +1,310 @@ +getTimestamp(); + } + + private static function zahl($value, string $name, float $low = -1.0e9, float $high = 1.0e9): float + { + if ((!is_float($value) && !is_int($value)) || !is_finite((float) $value) || $value < $low || $value > $high) { + throw new InvalidArgumentException($name . ': endlicher Zahlenwert erforderlich.'); + } + return (float) $value; + } + + private static function uuid($value): string + { + if (!is_string($value) || !preg_match('/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/D', $value)) { + throw new InvalidArgumentException('Ungueltige UUID.'); + } + return $value; + } + + public static function dekodieren(string $raw): array + { + if (strlen($raw) > self::MAX_RESPONSE_BYTES) { + throw new InvalidArgumentException('V4-Antwort zu gross.'); + } + $result = json_decode($raw, true, 64, JSON_THROW_ON_ERROR); + if (!is_array($result) || self::liste($result)) { + throw new InvalidArgumentException('V4-Antwort ist kein Objekt.'); + } + return $result; + } + + /** Stable local planning policy. Volatile SOC/availability are checked separately. */ + public static function kontext(array $operation): array + { + $assets = []; + foreach ($operation['batteries'] ?? [] as $b) { + if (!is_array($b) || !is_string($b['id'] ?? null) || isset($assets[$b['id']])) { + throw new InvalidArgumentException('Batteriezuordnung ungueltig.'); + } + $assets[$b['id']] = [ + 'capacityKwh' => self::zahl($b['capacityKwh'] ?? null, 'Kapazitaet', .001), + 'minSocPercent' => self::zahl($b['minSocPercent'] ?? null, 'Reserve', 0, 100), + 'maxSocPercent' => self::zahl($b['maxSocPercent'] ?? null, 'Maximal-SOC', 0, 100), + 'physicalMinSocPercent' => self::zahl($b['physicalMinSocPercent'] ?? 0.0, 'Minimum', 0, 100), + 'rearmSocPercent' => self::zahl($b['rearmSocPercent'] ?? $b['minSocPercent'], 'Hysterese', 0, 100), + 'gridCharging' => $b['gridCharging'] ?? null, + ]; + if (!is_bool($assets[$b['id']]['gridCharging'])) { + throw new InvalidArgumentException('Netzladefreigabe fehlt.'); + } + } + if (!$assets || count($assets) > 20 || !is_array($operation['limits'] ?? null)) { + throw new InvalidArgumentException('Batterien oder lokale Grenzen fehlen.'); + } + ksort($assets); + $limits = $operation['limits']; + $expected = ['importW', 'exportW', 'managerMonthLimitsW']; + if (count($limits) !== 3 || array_diff($expected, array_keys($limits)) || !is_array($limits['managerMonthLimitsW'])) { + throw new InvalidArgumentException('Unvollstaendige lokale Grenzen.'); + } + foreach (['importW', 'exportW'] as $key) { + if ($limits[$key] !== null) $limits[$key] = self::zahl($limits[$key], 'Grenze', 0); + } + foreach ($limits['managerMonthLimitsW'] as $month => $limit) { + if ((string) (int) $month !== (string) $month || (int) $month < 1 || (int) $month > 12) { + throw new InvalidArgumentException('Ungueltige Monatsgrenze.'); + } + $limits['managerMonthLimitsW'][$month] = self::zahl($limit, 'Monatsgrenze', 0); + } + ksort($limits['managerMonthLimitsW']); + return ['batteries' => $assets, 'limits' => $limits]; + } + + /** Authenticated endpoint must echo installation identity; old protocol is refused. */ + public static function pruefen(array $response, string $installation, array $operation, int $now, int $receivedAt): array + { + self::uuid($installation); + if (($response['receiverProtocolVersion'] ?? null) !== 1 + || ($response['installationId'] ?? null) !== $installation + || ($response['liveEnabled'] ?? null) !== false) { + throw new InvalidArgumentException('V4-Protokoll/Anlagenzuordnung oder Schattenmodus ungueltig.'); + } + if ($receivedAt > $now || $now - $receivedAt > self::MAX_RECEIPT_AGE) { + throw new InvalidArgumentException('Empfang veraltet; Vorschau verworfen.'); + } + $serverAt = self::zeit($response['checkedAt'] ?? null); + if ($serverAt > $now + 5 || $now - $serverAt > self::MAX_RECEIPT_AGE) { + throw new InvalidArgumentException('Serverantwort nicht aktuell.'); + } + if (($response['fresh'] ?? null) !== true || ($response['pending'] ?? null) !== null) { + throw new InvalidArgumentException('Server hat noch keinen aktuellen freigegebenen Schattenplan.'); + } + $settings = $response['settings'] ?? []; + $p = $response['plan'] ?? []; + if (!is_array($p) || !is_int($settings['revision'] ?? null) || $settings['revision'] < 0 + || ($p['schemaVersion'] ?? null) !== 2 || ($p['installationId'] ?? null) !== $installation + || ($p['configRevision'] ?? null) !== $settings['revision'] + || ($settings['runMode'] ?? null) !== 'shadow' + || ($p['runMode'] ?? null) !== 'shadow' || ($p['liveEnabled'] ?? null) !== false + || ($p['executable'] ?? null) !== true + || !in_array($p['status'] ?? '', ['optimal', 'feasible_time_limit'], true)) { + throw new InvalidArgumentException('Planrevision, Status oder Schattenmodus ungueltig.'); + } + self::uuid($p['planId'] ?? null); + if (($response['lastRun']['detail']['planId'] ?? null) !== $p['planId'] + || !in_array($response['lastRun']['status'] ?? '', ['optimal', 'feasible_time_limit'], true)) { + throw new InvalidArgumentException('Letzte Rechnung passt nicht zum empfangenen Plan.'); + } + $families = array_column($response['families'] ?? [], 'key'); + if (!is_string($p['sourceFamily'] ?? null) || !in_array($p['sourceFamily'], $families, true) + || (($settings['family'] ?? null) !== 'auto' && $settings['family'] !== $p['sourceFamily'])) { + throw new InvalidArgumentException('Modellfamilie unbekannt oder nicht ausgewaehlt.'); + } + $generated = self::zeit($p['generatedAt'] ?? null); + $start = self::zeit($p['validFrom'] ?? null); + $until = self::zeit($p['validUntil'] ?? null); + if ($generated > $now || $now - $generated > self::MAX_PLAN_AGE || $start > $now || $until <= $now + || $start !== $generated || $until - $start > 48 * 3600 + || self::zeit($p['pricesKnownUntil'] ?? null) < $until) { + throw new InvalidArgumentException('Plan abgelaufen, zukuenftig oder ohne bekannten Preishorizont.'); + } + $opAt = self::zeit($operation['observedAt'] ?? null); + if ($opAt > $now || $now - $opAt > 60) { + throw new InvalidArgumentException('Lokale Betriebsdaten veraltet.'); + } + $context = self::kontext($operation); + $pc = $p['controlContext'] ?? null; + if (!is_array($pc) || count($pc) !== 2 || !is_array($pc['batteries'] ?? null) || !is_array($pc['limits'] ?? null)) { + throw new InvalidArgumentException('Planungskontext fehlt.'); + } + $policyAssets = []; + foreach ($pc['batteries'] as $id => $policy) { + if (!is_array($policy) || count($policy) !== 6) { + throw new InvalidArgumentException('Unbekannter Batteriekontext.'); + } + $policyAssets[] = ['id' => $id] + $policy; + } + // Normalize explicitly typed numeric values; strings/bools are not numbers. + $plannedContext = self::kontext(['batteries' => $policyAssets, 'limits' => $pc['limits']]); + if ($plannedContext != $context) { + throw new InvalidArgumentException('Lokale Batterie-/Grenzkonfiguration passt nicht zum Plan.'); + } + if (!is_array($p['inputRefs'] ?? null) || !is_string($p['inputRefs']['operation'] ?? null) + || $p['inputRefs']['operation'] === '') { + throw new InvalidArgumentException('Plan ohne nachvollziehbare Eingangsdaten.'); + } + self::zahl($settings['roundtripEfficiency'] ?? null, 'Wirkungsgrad', .01, 1); + $points = $p['points'] ?? null; + if (!is_array($points) || !self::liste($points) || count($points) < 1 || count($points) > 576) { + throw new InvalidArgumentException('Ungueltige Zeitschrittliste.'); + } + $current = null; + $last = $start; + $ids = array_keys($context['batteries']); + foreach ($points as $point) { + $a = self::zeit($point['time'] ?? null); + $b = self::zeit($point['validUntil'] ?? null); + if ($a !== $last || $b <= $a || $b - $a > 300 || $b % 300 !== 0) { + throw new InvalidArgumentException('Luecke/Ueberlappung oder falsches Fuenfminutenraster.'); + } + $last = $b; + $grid = self::zahl($point['gridTargetW'] ?? null, 'Netzziel'); + $bat = self::zahl($point['batteryTargetW'] ?? null, 'Batterieziel'); + $base = self::zahl($point['baselineGridW'] ?? null, 'Basisnetz'); + $curtail = self::zahl($point['pvCurtailmentW'] ?? null, 'PV-Abregelung', 0); + if (abs($base + $bat + $curtail - $grid) > 1.0) { + throw new InvalidArgumentException('Leistungsbilanz des Plans verletzt.'); + } + if (!in_array($point['intent'] ?? '', ['hold', 'gridCharge', 'pvCharge', 'discharge', 'export'], true)) { + throw new InvalidArgumentException('Unbekannte Planabsicht.'); + } + $targets = $point['assetTargetsW'] ?? null; + $socs = $point['socEndPercent'] ?? null; + if (!is_array($targets) || !is_array($socs)) { + throw new InvalidArgumentException('Batterieziele fehlen.'); + } + $keys = array_keys($targets);sort($keys);$socKeys = array_keys($socs);sort($socKeys); + if ($keys !== $ids || $socKeys !== $ids) { + throw new InvalidArgumentException('Plan enthaelt falsche Batteriezuordnung.'); + } + $sum = 0.0; + foreach ($targets as $id => $w) { + $sum += self::zahl($w, 'Einzelziel'); + self::zahl($socs[$id], 'Plan-SOC', 0, 100); + } + if (abs($sum - $bat) > 1.0) { + throw new InvalidArgumentException('Summe der Batterieziele passt nicht.'); + } + foreach (['importLimitW', 'exportLimitW'] as $limit) { + if (!array_key_exists($limit, $point)) { + throw new InvalidArgumentException('Plangrenze fehlt.'); + } + if ($point[$limit] !== null) { + $v = self::zahl($point[$limit], 'Plangrenze', 0); + if (($limit === 'importLimitW' ? $grid : -$grid) > $v + 1) { + throw new InvalidArgumentException('Netzgrenze im Plan verletzt.'); + } + } + } + if ($a <= $now && $now < $b) { + $current = $point; + } + } + if ($last !== $until || $current === null) { + throw new InvalidArgumentException('Kein derzeit gueltiger Planabschnitt.'); + } + return ['plan' => $p, 'point' => $current, 'efficiency' => (float) $settings['roundtripEfficiency']]; + } + + /** Proposed single-battery correction only. Never use this result as a dispatch permit. */ + public static function vorschau(array $checked, array $operation, array $actualBatteryW, int $now): array + { + $point = $checked['point'];$p = $checked['plan']; + if (count($operation['batteries']) !== 1) { + throw new InvalidArgumentException('Lokale Vorschau fuer mehrere Batterien noch nicht freigegeben.'); + } + $b = $operation['batteries'][0];$id = $b['id']; + $age = $now - self::zeit($b['measuredAt'] ?? null); + if ($age < 0 || $age > 60) { + throw new InvalidArgumentException('Batteriemessung veraltet.'); + } + $grid = self::zahl($operation['gridW'], 'Netzleistung'); + $battery = self::zahl($actualBatteryW[$id] ?? null, 'Batterie-Istleistung'); + $soc = self::zahl($b['socPercent'], 'Aktueller SOC', $b['physicalMinSocPercent'] ?? 0, $b['maxSocPercent']); + $remainingHours = (self::zeit($point['validUntil']) - $now) / 3600; + if ($remainingHours <= 0) { + throw new InvalidArgumentException('Planabschnitt abgelaufen.'); + } + $eta = sqrt($checked['efficiency']);$capacity = self::zahl($b['capacityKwh'], 'Kapazitaet', .001); + $charge = min(self::zahl($b['maxChargeW'], 'Ladeleistung', 0), + max(0., $b['maxSocPercent'] - $soc) * $capacity / 100 / $eta / $remainingHours * 1000); + $discharge = min(self::zahl($b['maxDischargeW'], 'Entladeleistung', 0), + max(0., $soc - $b['minSocPercent']) * $capacity / 100 * $eta / $remainingHours * 1000); + $reasons = []; + if (($b['dischargeBlocked'] ?? false) === true || $soc <= $b['minSocPercent']) { + $discharge = 0.0;$reasons[] = 'Lokale Entladesperre/Reserve'; + } + $residual = $grid - $battery; + if ($b['gridCharging'] !== true) { + $charge = min($charge, max(0., -$residual));$reasons[] = 'Netzladen lokal gesperrt'; + } + $target = (float) $point['gridTargetW'];$limits = $operation['limits']; + $import = $limits['importW'];$export = $limits['exportW']; + $month = (int) (new DateTimeImmutable('@' . $now))->setTimezone(new DateTimeZone('Europe/Zurich'))->format('n'); + if (isset($limits['managerMonthLimitsW'][(string) $month])) { + $monthly = self::zahl($limits['managerMonthLimitsW'][(string) $month], 'Monatsgrenze', 0); + $import = $import === null ? $monthly : min($import, $monthly); + } + if ($import !== null) $target = min($target, self::zahl($import, 'Bezugsgrenze', 0)); + if ($export !== null) $target = max($target, -self::zahl($export, 'Einspeisegrenze', 0)); + $raw = $battery + $target - $grid; + $wanted = $raw; + if (abs($target) > 1.0) { + $wanted = match ($point['intent']) { + 'gridCharge' => max(0., $wanted), + 'pvCharge' => max(0., min(max(0., -$residual), $wanted)), + 'discharge', 'export' => min(0., $wanted), + 'hold' => 0., + }; + } + // Hard connection limits override economic intent, never BMS/availability. + if ($import !== null) $wanted = min($wanted, $import - $residual); + if ($export !== null) $wanted = max($wanted, -$export - $residual); + $clipped = min($charge, max(-$discharge, $wanted)); + if (abs($clipped - $raw) > 1) $reasons[] = 'Leistung, SOC, Planabsicht oder Netzgrenze begrenzt'; + $expectedGrid = $residual + $clipped; + $violation = max(0., $import === null ? 0. : $expectedGrid - $import, $export === null ? 0. : -$export - $expectedGrid); + if ($violation > 1) $reasons[] = 'Netzgrenze durch Batterie allein nicht erreichbar; lokale Schutzregelung erforderlich'; + return ['status' => 'preview_only', 'liveEnabled' => false, 'canDispatch' => false, + 'installationId' => $p['installationId'], 'planId' => $p['planId'], 'revision' => $p['configRevision'], + 'family' => $p['sourceFamily'], 'checkedAt' => gmdate('c', $now), 'step' => $point['time'], + 'stepValidUntil' => $point['validUntil'], 'gridTargetW' => (float) $point['gridTargetW'], + 'plannedBatteryW' => (float) $point['batteryTargetW'], 'previewBatteryW' => $clipped, + 'actualGridW' => $grid, 'actualBatteryW' => $battery, 'expectedGridW' => $expectedGrid, + 'unmetGridLimitW' => $violation, 'reasons' => $reasons, 'inputWarnings' => $p['warnings'] ?? [], + 'validityNote' => 'Nur Momentvorschau; keine Stellfreigabe und keine nachgewiesene Netzreaktion.']; + } +} diff --git a/tests/NetzfahrplanV4EmpfangTest.php b/tests/NetzfahrplanV4EmpfangTest.php new file mode 100644 index 0000000..fd22d29 --- /dev/null +++ b/tests/NetzfahrplanV4EmpfangTest.php @@ -0,0 +1,29 @@ + ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes); + self::assertIsResource($process); + fclose($pipes[0]); + $out = stream_get_contents($pipes[1]);$err = stream_get_contents($pipes[2]); + fclose($pipes[1]);fclose($pipes[2]); + self::assertSame(0, proc_close($process), $out . $err); + self::assertStringContainsString('Total:', $out); + } + + public function scripts(): array + { + return [['protocol_checks.php'], ['receiver_checks.php']]; + } +} diff --git a/tests/V4Receiver/fixture.php b/tests/V4Receiver/fixture.php new file mode 100644 index 0000000..b8afa19 --- /dev/null +++ b/tests/V4Receiver/fixture.php @@ -0,0 +1,30 @@ + 'op-original', 'observedAt' => gmdate('c', $now), 'gridW' => -3000.0, + 'batteries' => [['id' => 'b', 'capacityKwh' => 20.0, 'socPercent' => 50.0, 'minSocPercent' => 15.0, + 'maxSocPercent' => 90.0, 'physicalMinSocPercent' => 3.0, 'rearmSocPercent' => 17.0, + 'gridCharging' => true, 'dischargeBlocked' => false, 'maxChargeW' => 5000.0, 'maxDischargeW' => 4000.0, + 'measuredAt' => gmdate('c', $now)]], + 'limits' => ['importW' => 25000.0, 'exportW' => 25000.0, 'managerMonthLimitsW' => []]]; + $points = []; + for ($i = 0; $i < 6; $i++) $points[] = ['time' => gmdate('c', $now + $i*300), 'validUntil' => gmdate('c', $now + ($i+1)*300), + 'baselineGridW' => -3000.0, 'gridTargetW' => 0.0, 'batteryTargetW' => 3000.0, 'pvCurtailmentW' => 0.0, + 'assetTargetsW' => ['b' => 3000.0], 'socEndPercent' => ['b' => 50.0 + $i], 'intent' => 'pvCharge', + 'importLimitW' => 25000.0, 'exportLimitW' => 25000.0]; + $plan = ['schemaVersion' => 2, 'planId' => '10000000-0000-4000-8000-000000000001', 'installationId' => $id, + 'configRevision' => 1, 'sourceFamily' => '3', 'runMode' => 'shadow', 'liveEnabled' => false, 'executable' => true, + 'status' => 'optimal', 'generatedAt' => gmdate('c', $now), 'validFrom' => gmdate('c', $now), + 'validUntil' => gmdate('c', $now + 1800), 'pricesKnownUntil' => gmdate('c', $now + 1800), + 'controlContext' => \Belevo\EnelixEMS\NetzfahrplanV4Planpruefung::kontext($operation), + 'inputRefs' => ['operation' => 'op-original', 'forecast' => 'fc-original', 'tariffs' => 'tar-original'], + 'points' => $points, 'warnings' => ['Estimated peak; shadow only']]; + $response = ['receiverProtocolVersion' => 1, 'installationId' => $id, 'checkedAt' => gmdate('c', $now), + 'liveEnabled' => false, 'fresh' => true, 'pending' => null, + 'settings' => ['revision' => 1, 'family' => '3', 'runMode' => 'shadow', 'roundtripEfficiency' => .9], + 'families' => [['key' => '3'], ['key' => '13'], ['key' => '23']], 'plan' => $plan, + 'lastRun' => ['status' => 'optimal', 'detail' => ['planId' => $plan['planId']]]]; + return [$response, $id, $operation, $now]; +} diff --git a/tests/V4Receiver/protocol_checks.php b/tests/V4Receiver/protocol_checks.php new file mode 100644 index 0000000..de7cd5b --- /dev/null +++ b/tests/V4Receiver/protocol_checks.php @@ -0,0 +1,73 @@ +0.0],$now); +check($v['previewBatteryW']===3000.0 && $v['canDispatch']===false && $v['liveEnabled']===false,'zero grid uses actual surplus, never permission'); +invalid('wrong installation',fn(&$r)=>$r['installationId']='00000000-0000-4000-8000-000000000001'); +invalid('foreign plan',fn(&$r)=>$r['plan']['installationId']='00000000-0000-4000-8000-000000000001'); +invalid('old protocol',function(&$r){unset($r['receiverProtocolVersion']);}); +invalid('revision mismatch',fn(&$r)=>$r['plan']['configRevision']=2); +invalid('boolean revision',fn(&$r)=>$r['plan']['configRevision']=true); +invalid('live response',fn(&$r)=>$r['liveEnabled']=true); +invalid('live plan',fn(&$r)=>$r['plan']['runMode']='live'); +invalid('fake executable',fn(&$r)=>$r['plan']['executable']='true'); +invalid('stale server flag',fn(&$r)=>$r['fresh']=false); +invalid('pending computation',fn(&$r)=>$r['pending']=['reason'=>'new price']); +invalid('last run different plan',fn(&$r)=>$r['lastRun']['detail']['planId']='wrong'); +invalid('last run invalid',fn(&$r)=>$r['lastRun']['status']='invalid_inputs'); +invalid('family mismatch',fn(&$r)=>$r['settings']['family']='23'); +invalid('unknown family',fn(&$r)=>$r['plan']['sourceFamily']='unregistered'); +invalid('receipt stale',function(&$r,&$op,&$now,&$receipt){$receipt-=91;}); +invalid('future receipt',function(&$r,&$op,&$now,&$receipt){$receipt++;}); +invalid('stale server response',function(&$r)use($now){$r['checkedAt']=gmdate('c',$now-91);}); +invalid('future plan',function(&$r)use($now){$r['plan']['generatedAt']=gmdate('c',$now+10);}); +invalid('stale plan',function(&$r)use($now){$r['plan']['generatedAt']=gmdate('c',$now-901);}); +invalid('price horizon too short',function(&$r)use($now){$r['plan']['pricesKnownUntil']=gmdate('c',$now+300);}); +invalid('stale local operation',function(&$r,&$op)use($now){$op['observedAt']=gmdate('c',$now-61);}); +invalid('changed local reserve',fn(&$r,&$op)=>$op['batteries'][0]['minSocPercent']=30.0); +invalid('changed local grid limit',fn(&$r,&$op)=>$op['limits']['importW']=2000.0); +invalid('typed context required',fn(&$r)=>$r['plan']['controlContext']['batteries']['b']['capacityKwh']='20'); +invalid('input provenance missing',function(&$r){unset($r['plan']['inputRefs']);}); +invalid('unknown assets',function(&$r){$r['plan']['points'][0]['assetTargetsW']=['c'=>3000];}); +invalid('overlap',function(&$r){$r['plan']['points'][1]['time']=$r['plan']['points'][0]['time'];}); +invalid('gap',function(&$r)use($now){$r['plan']['points'][1]['time']=gmdate('c',$now+310);}); +invalid('bool target',fn(&$r)=>$r['plan']['points'][0]['gridTargetW']=false); +invalid('string target',fn(&$r)=>$r['plan']['points'][0]['gridTargetW']='0'); +invalid('NaN target',fn(&$r)=>$r['plan']['points'][0]['batteryTargetW']=NAN); +invalid('broken balance',fn(&$r)=>$r['plan']['points'][0]['baselineGridW']=10000.0); +invalid('unknown intent',fn(&$r)=>$r['plan']['points'][0]['intent']='discharge_all'); +invalid('no timezone',fn(&$r)=>$r['plan']['points'][0]['time']='2026-10-02T12:00:00'); +invalid('bad calendar date',fn(&$r)=>$r['plan']['points'][0]['time']='2026-02-30T12:00:00Z'); +invalid('missing step',fn(&$r)=>$r['plan']['points']=[]); +invalid('negative curtailment',fn(&$r)=>$r['plan']['points'][0]['pvCurtailmentW']=-100.0); +[$r,$id,$op,$now]=receiverFixture();$r['checkedAt']=$op['observedAt']=gmdate('c',$now+300);$op['batteries'][0]['measuredAt']=$op['observedAt']; +$x=P::pruefen($r,$id,$op,$now+300,$now+300); +check($x['point']['time']===gmdate('c',$now+300),'half-open boundary picks current, not interpolated'); +[$r,$id,$op,$now]=receiverFixture();$r['plan']['sourceFamily']=$r['settings']['family']='future-family';$r['families'][]=['key'=>'future-family']; +check(P::pruefen($r,$id,$op,$now,$now)['plan']['sourceFamily']==='future-family','registry is extensible'); +[$r,$id,$op,$now]=receiverFixture();$op['gridW']=3000.0;$op['batteries'][0]['dischargeBlocked']=true; +$x=P::pruefen($r,$id,$op,$now,$now);$v=P::vorschau($x,$op,['b'=>0],$now); +check($v['previewBatteryW']===0.0,'local hysteresis prevents preview discharge'); +[$r,$id,$op,$now]=receiverFixture();$op['gridW']=3000.0;$op['batteries'][0]['socPercent']=15.001; +$x=P::pruefen($r,$id,$op,$now,$now);$v=P::vorschau($x,$op,['b'=>0],$now); +check($v['previewBatteryW']>-3 && $v['previewBatteryW']<0,'remaining-step energy cannot cross reserve'); +[$r,$id,$op,$now]=receiverFixture();$op['batteries'][0]['socPercent']=90; +$x=P::pruefen($r,$id,$op,$now,$now);$v=P::vorschau($x,$op,['b'=>0],$now);check($v['previewBatteryW']===0.0,'full battery cannot charge'); +[$r,$id,$op,$now]=receiverFixture();$op['batteries'][0]['maxChargeW']=0;$op['limits']['exportW']=0.;$r['plan']['controlContext']=P::kontext($op); +$x=P::pruefen($r,$id,$op,$now,$now);$v=P::vorschau($x,$op,['b'=>0],$now);check($v['unmetGridLimitW']===3000.0 && !$v['canDispatch'],'zero export limit reports unmet constraint'); +[$r,$id,$op,$now]=receiverFixture();$x=P::pruefen($r,$id,$op,$now,$now);$op['gridW']=2000;$op['batteries'][0]['gridCharging']=false; +$x['point']['gridTargetW']=8000.;$x['point']['intent']='gridCharge';$v=P::vorschau($x,$op,['b'=>0],$now);check($v['previewBatteryW']===0.,'grid charging opt-in enforced'); +$op['batteries'][0]['gridCharging']=true;$x['point']['intent']='hold';$v=P::vorschau($x,$op,['b'=>0],$now);check($v['previewBatteryW']===0.,'nonzero hold does not accidentally charge'); +$x['point']['gridTargetW']=0.;$x['point']['intent']='discharge';$op['gridW']=0.;$v=P::vorschau($x,$op,['b'=>-2000],$now);check($v['previewBatteryW']===-2000.,'uses actual battery feedback, not incremental zero'); +try { P::dekodieren(str_repeat('x',P::MAX_RESPONSE_BYTES+1));check(false,'body limit'); } catch (InvalidArgumentException $e) {check(true,'body limit');} +echo "Total: $count checks. Synthetic data only.\n"; diff --git a/tests/V4Receiver/receiver_checks.php b/tests/V4Receiver/receiver_checks.php new file mode 100644 index 0000000..31cd174 --- /dev/null +++ b/tests/V4Receiver/receiver_checks.php @@ -0,0 +1,117 @@ +$name) { + if (!defined($name)) define($name,50000+$i); + } + function time() { return $GLOBALS['clock']; } + function IPS_SemaphoreEnter($name,$wait) { if ($GLOBALS['lock']) return false;$GLOBALS['lock']=true;return true; } + function IPS_SemaphoreLeave($name) { $GLOBALS['lock']=false; } + function IPS_VariableExists($id) {return $id===100;} + function IPS_GetVariable($id) {return ['VariableUpdated'=>$GLOBALS['clock']];} + function GetValue($id) {return $GLOBALS['actual'];} + function curl_init($url) { return (object)['url'=>$url,'options'=>[],'code'=>0]; } + function curl_setopt_array($h,$options) { $h->options=$options;return true; } + function curl_getinfo($h,$key) { return $h->code; } + function curl_close($h) {} + function curl_exec($h) { + $GLOBALS['requests'][]=['url'=>$h->url,'options'=>$h->options]; + $ack=str_ends_with($h->url,'/ack'); + $h->code=$ack?$GLOBALS['ackCode']:$GLOBALS['getCode']; + $body=$ack?['status'=>'recorded']:$GLOBALS['response']; + $json=json_encode($body,JSON_THROW_ON_ERROR); + $callback=$h->options[CURLOPT_WRITEFUNCTION]; + return $callback($h,$json)===strlen($json); + } + final class ReceiverHarness { + use ManagerNetzfahrplanV4EmpfangTrait; + public int $InstanceID=17004; + public array $props=[],$attrs=[],$vars=[],$timers=[]; + public bool $licensed=true; + public array $op=[]; + public function __construct($id,$op) { + $this->op=$op; + $this->props=['NetzfahrplanV4EmpfangAktiv'=>false,'NetzfahrplanV4SchattenAktiv'=>true, + 'NetzfahrplanAktiv'=>false,'MesswertMaxAlter'=>60, + 'AnlagenBatterien'=>json_encode([['ID'=>'b','LeistungVariableID'=>100,'Leistungsfaktor'=>1]])]; + $this->attrs=['LizenzInstallationID'=>$id,'PrognoseInstallationsToken'=>str_repeat('x',43)]; + $this->registriereNetzfahrplanV4Empfang(); + } + public function receive() {$this->empfangeNetzfahrplanV4();} + public function update() {$this->aktualisiereNetzfahrplanV4Vorschau();} + public function stop() {$this->stoppeNetzfahrplanV4Vorschau();} + public function configure() {$this->konfiguriereNetzfahrplanV4Vorschau();} + public function form() {return $this->netzfahrplanV4EmpfangFormular();} + public function GetNetzfahrplanV4Diagnose() { return json_encode(['operation'=>$this->op]); } + private function berechtigungLizenziert($key) {return $this->licensed;} + private function ReadPropertyBoolean($key) {return (bool)$this->props[$key];} + private function ReadPropertyString($key) {return $this->props[$key];} + private function ReadPropertyInteger($key) {return (int)$this->props[$key];} + private function ReadAttributeString($key) {return $this->attrs[$key]??'';} + private function ReadAttributeInteger($key) {return (int)($this->attrs[$key]??0);} + private function WriteAttributeString($key,$val) { + if (!str_starts_with($key,'NetzfahrplanV4')) throw new \RuntimeException('Unexpected external attribute'); + $this->attrs[$key]=$val; + } + private function WriteAttributeInteger($key,$val) { $this->attrs[$key]=$val; } + private function RegisterPropertyBoolean($key,$default) {$this->props[$key]??=$default;} + private function RegisterAttributeString($key,$default) {$this->attrs[$key]??=$default;} + private function RegisterAttributeInteger($key,$default) {$this->attrs[$key]??=$default;} + private function RegisterVariableString($key,$name,$profile,$pos) {$this->vars[$key]='';} + private function RegisterTimer($key,$interval,$script) {$this->timers[$key]=$interval;} + private function SetTimerInterval($key,$interval) {$this->timers[$key]=$interval;} + private function SetValue($key,$value) { + if (!in_array($key,['NetzfahrplanV4Vorschau','NetzfahrplanV4VorschauJSON'],true)) throw new \RuntimeException('Actuator/foreign variable write'); + $this->vars[$key]=$value; + } + } +} +namespace { + require_once __DIR__.'/fixture.php'; + use Belevo\EnelixEMS\ReceiverHarness; + $count=0; + function check($ok,$name) {global $count;if (!$ok) throw new RuntimeException('FAIL '.$name);$count++;echo 'PASS '.$name."\n";} + function fixture() { + [$r,$id,$op,$now]=receiverFixture(); + $GLOBALS['response']=$r;$GLOBALS['clock']=$now;$GLOBALS['actual']=0.;$GLOBALS['requests']=[]; + $GLOBALS['lock']=false;$GLOBALS['getCode']=200;$GLOBALS['ackCode']=200; + return new ReceiverHarness($id,$op); + } + function advance($h,$seconds) { + $GLOBALS['clock']+=$seconds;$GLOBALS['response']['checkedAt']=gmdate('c',$GLOBALS['clock']); + $h->op['observedAt']=gmdate('c',$GLOBALS['clock']);$h->op['batteries'][0]['measuredAt']=$h->op['observedAt']; + } + $h=fixture();$h->receive();check(count($requests)===0,'default disabled no network'); + $h->props['NetzfahrplanV4EmpfangAktiv']=true;$h->configure();check($h->timers['NetzfahrplanV4VorschauAktualisieren']===10000,'watchdog without network'); + $h->receive();$v=json_decode($h->GetNetzfahrplanV4Vorschau(),true); + check($v['status']==='preview_only' && $v['previewBatteryW']===3000 && $v['canDispatch']===false,'read and show only'); + check(count($requests)===2 && str_ends_with($requests[1]['url'],'/ack'),'one read and one acknowledgement'); + $body=json_decode($requests[1]['options'][CURLOPT_POSTFIELDS],true);check($body['status']==='shadow_seen' && $body['step']===$response['plan']['points'][0]['time'],'ack is shadow_seen, never applied'); + $h->receive();check(count($requests)===2,'repeated click throttled'); + advance($h,60);$h->receive();check(count($requests)===3,'same plan step not acknowledged twice'); + advance($h,91);$h->update();$v=json_decode($h->vars['NetzfahrplanV4VorschauJSON'],true); + check($v['status']==='unavailable' && $v['previewBatteryW']===null,'expired cache clears numeric targets, not fake zero'); + check(!str_contains($h->vars['NetzfahrplanV4Vorschau'],'3000'),'expired HTML clears previous setpoint'); + $h=fixture();$h->props['NetzfahrplanV4EmpfangAktiv']=true;$h->receive();advance($h,61);$GLOBALS['getCode']=429;$h->receive(); + check($h->attrs['NetzfahrplanV4Empfang']==='{}' && json_decode($h->GetNetzfahrplanV4Vorschau(),true)['previewBatteryW']===null,'429 invalidates cache'); + check(!str_contains(json_encode($h->vars),str_repeat('x',43)),'no credentials in diagnosis'); + $h=fixture();$h->props['NetzfahrplanV4EmpfangAktiv']=true;$h->props['NetzfahrplanAktiv']=true;$h->receive();check(count($requests)===0,'legacy execution on blocks preview acquisition'); + $h=fixture();$h->props['NetzfahrplanV4EmpfangAktiv']=true;$GLOBALS['response']['installationId']='00000000-0000-4000-8000-000000000001';$h->receive(); + check(count($requests)===1 && $h->attrs['NetzfahrplanV4Empfang']==='{}','foreign response not acknowledged'); + $h=fixture();$h->props['NetzfahrplanV4EmpfangAktiv']=true;$GLOBALS['ackCode']=503;$h->receive();$v=json_decode($h->GetNetzfahrplanV4Vorschau(),true); + check($v['status']==='preview_only' && !$v['reception']['acknowledged'] && isset($v['reception']['acknowledgementError']),'ack failure is visible, no false execution confirmation'); + $h=fixture();$h->props['NetzfahrplanV4EmpfangAktiv']=true;$h->receive();$h->op['batteries'][0]['minSocPercent']=30.;$h->update(); + check(json_decode($h->vars['NetzfahrplanV4VorschauJSON'],true)['previewBatteryW']===null,'local policy change invalidates cache'); + $h=fixture();$h->props['NetzfahrplanV4EmpfangAktiv']=true;$h->receive();$h->stop();check($h->attrs['NetzfahrplanV4Empfang']==='{}' && $h->timers['NetzfahrplanV4VorschauAktualisieren']===0,'apply/restart invalidates cached plan'); + $h=fixture();$h->props['NetzfahrplanV4EmpfangAktiv']=true;$h->licensed=false;$h->receive();check(count($requests)===0,'no license no read'); + $h=fixture();$h->props['NetzfahrplanV4EmpfangAktiv']=true;$GLOBALS['lock']=true;$h->receive();check(count($requests)===0,'concurrent receiver does not send'); + $h=fixture();$h->props['NetzfahrplanV4EmpfangAktiv']=true;$h->receive();$h->props['NetzfahrplanV4EmpfangAktiv']=false;$h->update();check(json_decode($h->vars['NetzfahrplanV4VorschauJSON'],true)['previewBatteryW']===null,'disable clears displayed target'); + $form=$h->form();check($form['items'][1]['name']==='NetzfahrplanV4EmpfangAktiv','separate dynamic form field'); + $source=file_get_contents(__DIR__.'/../../libs/ManagerNetzfahrplanV4EmpfangTrait.php'); + check(!preg_match('/\b(?:SendDataToChildren|SendDataToParent|sendeManagerdaten|regeln|SetStatus|EnableAction|IPS_SetProperty|IPS_ApplyChanges)\s*\(/',$source),'no control or external configuration call in receiver'); + check(!str_contains($source,"'status' => 'applied'"),'never acknowledges applied'); + echo "Total: $count checks. Mocked IPS and HTTP only.\n"; +}