feat(manager): receive and preview V4 shadow plans without dispatch

This commit is contained in:
ENELIX Agent
2026-10-02 13:01:04 +00:00
parent fc4c4632ce
commit b5c1fa6be2
9 changed files with 847 additions and 0 deletions
+11
View File
@@ -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;
+48
View File
@@ -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.
+223
View File
@@ -0,0 +1,223 @@
<?php
declare(strict_types=1);
namespace Belevo\EnelixEMS;
use RuntimeException;
use Throwable;
require_once __DIR__ . '/NetzfahrplanV4Planpruefung.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 / 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 = '<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);
}
}
+6
View File
@@ -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.');
+310
View File
@@ -0,0 +1,310 @@
<?php
declare(strict_types=1);
namespace Belevo\EnelixEMS;
use DateTimeImmutable;
use DateTimeZone;
use InvalidArgumentException;
/** Pure observation-only validator. No IPS calls, transport, timers or actuator writes. */
final class NetzfahrplanV4Planpruefung
{
public const MAX_RESPONSE_BYTES = 2000000;
public const MAX_PLAN_AGE = 900;
public const MAX_RECEIPT_AGE = 90;
private static function liste(array $value): bool
{
return $value === [] || array_keys($value) === range(0, count($value) - 1);
}
public static function zeit($value): int
{
if (!is_string($value) || !preg_match('/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,6})?(?:Z|[+-]\d{2}:\d{2})$/D', $value)) {
throw new InvalidArgumentException('Zeitpunkt ohne eindeutige Zeitzone.');
}
$time = new DateTimeImmutable($value);
$errors = DateTimeImmutable::getLastErrors();
if ($errors !== false && ($errors['warning_count'] || $errors['error_count'])) {
throw new InvalidArgumentException('Ungueltiger Kalenderzeitpunkt.');
}
return $time->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.'];
}
}
+29
View File
@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace Belevo\EnelixEMS\Tests;
use PHPUnit\Framework\TestCase;
final class NetzfahrplanV4EmpfangTest extends TestCase
{
/** @dataProvider scripts */
public function testIsolatedReceiverChecks(string $script): void
{
$pipes = [];
$process = proc_open([PHP_BINARY, __DIR__ . '/V4Receiver/' . $script],
[0 => ['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']];
}
}
+30
View File
@@ -0,0 +1,30 @@
<?php
function receiverFixture(): array
{
$now = strtotime('2026-10-02T12:00:00Z');
$id = 'e3a08f9e-af12-4695-99bd-8b51c0520021';
$operation = ['eventId' => '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];
}
+73
View File
@@ -0,0 +1,73 @@
<?php
require_once __DIR__ . '/../../libs/NetzfahrplanV4Planpruefung.php';
require_once __DIR__ . '/fixture.php';
use Belevo\EnelixEMS\NetzfahrplanV4Planpruefung as P;
$count = 0;
function check($ok, string $label): void { global $count; if (!$ok) throw new RuntimeException('FAIL ' . $label); $count++;echo 'PASS ' . $label . "\n"; }
function invalid(string $name, callable $change): void {
[$r,$id,$op,$now] = receiverFixture();$receipt=$now;
$change($r,$op,$now,$receipt);
try { P::pruefen($r,$id,$op,$now,$receipt); } catch (Throwable $e) { check(true,$name);return; }
check(false,$name);
}
[$r,$id,$op,$now]=receiverFixture();
$x=P::pruefen($r,$id,$op,$now,$now);$v=P::vorschau($x,$op,['b'=>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";
+117
View File
@@ -0,0 +1,117 @@
<?php
namespace Belevo\EnelixEMS {
require_once __DIR__ . '/../../libs/ManagerNetzfahrplanV4EmpfangTrait.php';
class Lizenzpruefung { public const NETZFAHRPLAN = 'grid_schedule'; }
foreach (['CURLOPT_CUSTOMREQUEST','CURLOPT_FOLLOWLOCATION','CURLOPT_CONNECTTIMEOUT','CURLOPT_TIMEOUT',
'CURLOPT_SSL_VERIFYPEER','CURLOPT_SSL_VERIFYHOST','CURLOPT_PROTOCOLS','CURLOPT_HTTPHEADER',
'CURLOPT_WRITEFUNCTION','CURLOPT_POSTFIELDS','CURLINFO_HTTP_CODE','CURLPROTO_HTTPS'] as $i=>$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";
}