feat(workflow): publish verified forecast controls and consolidated docs
Tests / test (push) Successful in 1m1s

Approved by Daniel Haefliger for develop and beta. Author dh_Agent, authenticated account dh. Preserve published battery, charging and overall Energy Pie changes. No deployment or plant control authorization.
This commit is contained in:
dh
2026-10-08 10:02:58 +00:00
parent af69151165
commit 74a2d6c685
82 changed files with 5054 additions and 617 deletions
+17 -6
View File
@@ -10,8 +10,8 @@ $GLOBALS['deviceCalls'] = 0;
$GLOBALS['testParent'] = 20;
function MBUS_UpdateValues(int $id): bool { ++$GLOBALS['deviceCalls']; throw new RuntimeException('Unexpected device I/O'); }
function ModBus_RequestRead(int $id): bool { ++$GLOBALS['deviceCalls']; throw new RuntimeException('Unexpected device I/O'); }
function IPS_VariableExists(int $id): bool { return $id === 10; }
function IPS_GetObject(int $id): array { return ['ParentID' => $GLOBALS['testParent'], 'ObjectIdent' => 'grid']; }
function IPS_VariableExists(int $id): bool { return in_array($id, [10, 11], true); }
function IPS_GetObject(int $id): array { return ['ParentID' => $GLOBALS['testParent'], 'ObjectIdent' => $id === 10 ? 'grid' : 'battery']; }
function IPS_GetInstanceListByModuleID(string $id): array { return [17004]; }
function IPS_GetProperty(int $id, string $key): mixed
{
@@ -93,15 +93,26 @@ foreach ($expected as $name => $call) {
check([$battery->properties, $battery->attributes, $battery->buffers] === $before,
'timer entrypoint does not change configuration, energy or buffers');
$config = ['batteryInstanceId' => 44234, 'managerId' => 17004,
'sources' => [['variableId' => 10, 'parentId' => 20, 'ident' => 'grid']]];
$config = ['version' => 1, 'installationId' => '11111111-2222-4333-8444-555555555555', 'assetId' => 'battery',
'batteryInstanceId' => 44234, 'managerId' => 17004, 'mode' => 'physical_sum', 'allowEstimatedForTrial' => false,
'maxSkewSeconds' => 30, 'maxAgeSeconds' => 60, 'idleToleranceW' => 0.0, 'trackingToleranceW' => 0.0,
'sources' => [
['key' => 'grid', 'role' => 'grid', 'variableId' => 10, 'parentId' => 20, 'ident' => 'grid', 'factorToW' => 1.0],
['key' => 'battery', 'role' => 'physical', 'variableId' => 11, 'parentId' => 20, 'ident' => 'battery', 'factorToW' => 1.0],
]];
$json = json_encode($config, JSON_THROW_ON_ERROR);
$battery->properties['NetzfahrplanV4RueckmeldungKonfiguration'] = $json;
$sample = ['kind' => 'local_physical_feedback', 'checkedAt' => time(),
'sourceOldestAt' => time() - 1, 'deviceReadConfirmed' => true, 'batteryW' => -123.0];
'sourceOldestAt' => time() - 1, 'deviceReadConfirmed' => true, 'batteryW' => -123.0,
'installationId' => $config['installationId'], 'assetId' => $config['assetId'],
'batteryInstanceId' => 44234, 'managerId' => 17004,
'configHash' => \Belevo\EnelixEMS\NetzfahrplanV4Rueckmeldung::fingerprint($config)];
$validBuffers = ['V4ConfirmedFeedback' => json_encode($sample, JSON_THROW_ON_ERROR),
'V4ConfirmedFeedbackConfig' => hash('sha256', $json),
'V4ConfirmedFeedbackMono' => (string) (hrtime(true) / 1e9)];
'V4ConfirmedFeedbackMono' => (string) (hrtime(true) / 1e9),
'V4ConfirmedFeedbackContext' => json_encode([
'configuration' => $json, 'propertyHash' => hash('sha256', $json), 'autoMode' => false,
], JSON_THROW_ON_ERROR)];
$battery->buffers = $validBuffers;
check($battery->GetV4BatterieRueckmeldungCache() === $validBuffers['V4ConfirmedFeedback'],
'cache-only API returns original confirmed sample and timestamps');
+3 -2
View File
@@ -347,7 +347,7 @@ final class ManagerModulstrukturTest extends TestCase
self::assertStringContainsString('EinspeiseRegler::stellwert(', $inhalt);
self::assertStringContainsString("RegisterAttributeString('LetztePvGrenzen'", $inhalt);
self::assertStringContainsString("RegisterAttributeString('Netzfahrplan'", $inhalt);
self::assertStringContainsString(
self::assertStringNotContainsString(
'https://license.enelix.ch/api/v1/installations/%s/prognosis/schedule',
$inhalt
);
@@ -359,7 +359,8 @@ final class ManagerModulstrukturTest extends TestCase
"foreach (['PeakShavingTitel', 'EinspeisebegrenzungAktiv', 'Einspeisegrenze']",
$inhalt
);
self::assertStringContainsString('aktuelleNetzfahrplanVorgabe()', $inhalt);
self::assertStringNotContainsString('aktuelleNetzfahrplanVorgabe()', $inhalt);
self::assertStringContainsString('korrigierePrognoseBatterie(', $inhalt);
self::assertStringContainsString('gibEinspeisebegrenzungFrei()', $inhalt);
self::assertStringNotContainsString("RegisterPropertyFloat('Einspeisetoleranz'", $inhalt);
}
+32
View File
@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace Belevo\EnelixEMS\Tests;
use PHPUnit\Framework\TestCase;
final class ManagerPrognoseFormularTest extends TestCase
{
/** @dataProvider scripts */
public function testTwoSwitchWorkflow(string $script): void
{
$pipes = [];
$process = proc_open([PHP_BINARY, __DIR__ . '/PrognoseFormular/' . $script],
[0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes);
self::assertIsResource($process);
fclose($pipes[0]);
$output = stream_get_contents($pipes[1]);
$errors = stream_get_contents($pipes[2]);
fclose($pipes[1]);
fclose($pipes[2]);
self::assertSame(0, proc_close($process), $output . $errors);
self::assertSame('', $errors);
self::assertMatchesRegularExpression('/Total: [0-9]+ checks\./', $output);
}
public function scripts(): array
{
return [['checks.php'], ['manager_checks.php']];
}
}
+26
View File
@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace Belevo\EnelixEMS\Tests;
use PHPUnit\Framework\TestCase;
final class ManagerPrognoseWorkflowTest extends TestCase
{
public function testIsolatedWorkflowAndDispatchContracts(): void
{
$pipes = [];
$process = proc_open([PHP_BINARY, __DIR__ . '/PrognoseWorkflow/checks.php'],
[0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes);
self::assertIsResource($process);
fclose($pipes[0]);
$output = stream_get_contents($pipes[1]);
$errors = stream_get_contents($pipes[2]);
fclose($pipes[1]);
fclose($pipes[2]);
self::assertSame(0, proc_close($process), $output . $errors);
self::assertSame('', $errors);
self::assertMatchesRegularExpression('/TOTAL [0-9]+ workflow checks passed/', $output);
}
}
+5 -1
View File
@@ -23,7 +23,11 @@ final class NetzfahrplanV4BezugszaehlerTest extends TestCase
self::assertStringNotContainsString($forbidden, $source);
}
$trait = file_get_contents(__DIR__ . '/../libs/ManagerNetzfahrplanV4Trait.php');
self::assertStringNotContainsString("'NetzbezugEnergieVariableID'", $trait);
// The explicitly configured physical import-energy source is now shared;
// legacy calculated energy archives and power-to-energy guesses are not.
self::assertStringContainsString("'NetzbezugEnergieVariableID'", $trait);
self::assertStringContainsString("'FaktorZuKWh' => \$this->ReadPropertyFloat('NetzbezugEnergiefaktor')", $trait);
self::assertStringNotContainsString("GetValue('NetzbezugEnergie')", $trait);
self::assertStringContainsString("RegisterPropertyString('NetzfahrplanV4BezugszaehlerQuellen', '[]')", $trait);
}
}
+26
View File
@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace Belevo\EnelixEMS\Tests;
use PHPUnit\Framework\TestCase;
final class NetzfahrplanV4EinrichtungTest extends TestCase
{
public function testIsolatedOnboardingChecks(): void
{
$pipes = [];
$process = proc_open([PHP_BINARY, __DIR__ . '/V4Setup/checks.php'],
[0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes);
self::assertIsResource($process);
fclose($pipes[0]);
$output = stream_get_contents($pipes[1]);
$errors = stream_get_contents($pipes[2]);
fclose($pipes[1]);
fclose($pipes[2]);
self::assertSame(0, proc_close($process), $output . $errors);
self::assertSame('', $errors);
self::assertMatchesRegularExpression('/TOTAL [0-9]+ setup checks passed/', $output);
}
}
+26
View File
@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace Belevo\EnelixEMS\Tests;
use PHPUnit\Framework\TestCase;
final class NetzfahrplanV4RegelungTest extends TestCase
{
public function testIsolatedProductionAllocationChecks(): void
{
$pipes = [];
$process = proc_open([PHP_BINARY, __DIR__ . '/V4Control/checks.php'],
[0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes);
self::assertIsResource($process);
fclose($pipes[0]);
$output = stream_get_contents($pipes[1]);
$errors = stream_get_contents($pipes[2]);
fclose($pipes[1]);
fclose($pipes[2]);
self::assertSame(0, proc_close($process), $output . $errors);
self::assertSame('', $errors);
self::assertMatchesRegularExpression('/TOTAL [0-9]+ control checks passed/', $output);
}
}
@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace Belevo\EnelixEMS\Tests;
use PHPUnit\Framework\TestCase;
final class NetzfahrplanV4RueckmeldungEinrichtungTest extends TestCase
{
public function testIsolatedAutomaticFeedbackChecks(): void
{
$pipes = [];
$process = proc_open([PHP_BINARY, __DIR__ . '/V4FeedbackSetup/checks.php'],
[0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes);
self::assertIsResource($process);
fclose($pipes[0]);
$output = stream_get_contents($pipes[1]);
$errors = stream_get_contents($pipes[2]);
fclose($pipes[1]);
fclose($pipes[2]);
self::assertSame(0, proc_close($process), $output . $errors);
self::assertSame('', $errors);
self::assertMatchesRegularExpression('/TOTAL [0-9]+ feedback setup checks passed/', $output);
}
}
+78 -60
View File
@@ -20,91 +20,108 @@ function check(bool $ok, string $name): void
function fields(array $form): array
{
foreach ($form['elements'] as $element) {
if (($element['name'] ?? '') === 'PrognoseForecast') {
$fields = [];
foreach ($element['items'] as $field) {
if (isset($field['name'])) {
if (isset($fields[$field['name']])) {
throw new RuntimeException('Duplicate field: ' . $field['name']);
}
$fields[$field['name']] = $field;
}
}
return $fields;
if (($element['name'] ?? '') !== 'PrognoseForecast') {
continue;
}
$fields = [];
foreach ($element['items'] as $field) {
if (isset($field['name'])) {
if (isset($fields[$field['name']])) {
throw new RuntimeException('Duplicate field: ' . $field['name']);
}
$fields[$field['name']] = $field;
}
}
return $fields;
}
throw new RuntimeException('Forecast panel missing');
}
$anzeigen = ManagerPrognoseFormular::laufzeitAnzeige();
check(count($anzeigen) === 4, 'only known forecast runtime objects are consolidated');
check(count($anzeigen) === 4, 'known historical runtime identifiers remain available');
foreach ($anzeigen as $ident => $name) {
check(str_starts_with($ident, 'NetzfahrplanV4'), 'stable existing identifier retained');
check(!str_contains($name, 'V4'), 'technical version removed from display name');
check(!str_contains($name, 'V4'), 'technical version removed from historical display name');
}
check(str_contains($anzeigen['NetzfahrplanV4Aktivtest'], 'Testbetrieb'), 'test limitation remains visible');
check(str_contains($anzeigen['NetzfahrplanV4Aktivtest'], 'Testbetrieb'), 'historical test object is not relabelled as production control');
$module = file_get_contents(__DIR__ . '/../../Manager/module.php');
check(str_contains($module, 'ManagerPrognoseFormular::laufzeitAnzeige()'), 'runtime display migration is applied');
check(str_contains($module, 'IPS_SetHidden($objektID, true)'), 'legacy diagnostics are hidden, not deleted');
check(str_contains($module, "RegisterPropertyBoolean('NetzfahrplanAktiv', false)"), 'legacy controller property remains stored');
check(str_contains($module, "RegisterPropertyInteger('PrognoseSendeintervall'"), 'internal sending interval remains stored');
$propertySources = $module;
foreach (glob(__DIR__ . '/../../libs/ManagerNetzfahrplanV4*Trait.php') ?: [] as $path) {
$propertySources .= file_get_contents($path);
}
foreach (['NetzfahrplanV4EmpfangAktiv', 'NetzfahrplanV4SchattenAktiv', 'NetzfahrplanV4MessdatenAktiv',
'NetzfahrplanV4RegeltestErlaubt', 'NetzfahrplanV4AktivtestErlaubt', 'NetzfahrplanV4WatchdogVerzichtErlaubt'] as $property) {
check(preg_match("/RegisterPropertyBoolean\\(\\s*'" . $property . "'/", $propertySources) === 1,
'hidden technical property remains registered: ' . $property);
}
$base = json_decode(file_get_contents(__DIR__ . '/../../Manager/form.json'), true, 512, JSON_THROW_ON_ERROR);
$state = [
'prognoseAktiv' => true, 'lizenziert' => true, 'senderAktiv' => true, 'empfangAktiv' => true,
'messdatenAktiv' => true, 'legacyAktiv' => false, 'managerAktiv' => true, 'testErlaubt' => true,
'aktivtestErlaubt' => true, 'watchdogVerzichtErlaubt' => true, 'aktivtestAktiv' => false,
'ende' => 1791387487, 'jetzt' => 1791300000,
];
$original = $base;
$state = ['prognoseAktiv' => true, 'lizenziert' => true, 'ready' => true,
'controlRequested' => false, 'controlActive' => false, 'state' => 'ready', 'reason' => 'Lokale Pruefungen bestanden.'];
$form = ManagerPrognoseFormular::integrieren($base, $state);
$f = fields($form);
check(count($form['elements']) === count($base['elements']), 'no additional panel');
check(!str_contains(json_encode($form), 'V4 Planempfang'), 'separate V4 tab removed');
foreach (['PrognoseAktiv', 'NetzfahrplanAktiv', 'PrognoseSendeintervall', 'NetzfahrplanV4EmpfangAktiv',
'PrognoseNetzfahrplanStart', 'PrognoseNetzfahrplanStop', 'PrognoseNetzfahrplanPruefen',
'PrognoseLizenzVerwalten', 'PrognoseNetzfahrplanStatus'] as $name) {
check(isset($f[$name]), 'one forecast panel contains ' . $name);
$switches = array_keys(array_filter($f, static fn(array $field): bool => $field['type'] === 'CheckBox'));
check($switches === ['PrognoseAktiv', 'NetzfahrplanRegelungAktiv'], 'exactly learning and control switches are exposed');
check(str_contains($f['PrognoseAktiv']['onChange'], "'FormPrognoseAktiv'"), 'learning switch refreshes existing form guard');
check($f['NetzfahrplanRegelungAktiv']['enabled'] === true, 'ready licensed learning permits control request');
check(!isset($f['NetzfahrplanRegelungAktiv']['onChange']), 'control is a saved property, not a direct actuator action');
foreach (['NetzfahrplanAktiv', 'PrognoseSendeintervall', 'NetzfahrplanV4EmpfangAktiv',
'NetzfahrplanV4SchattenAktiv', 'NetzfahrplanV4MessdatenAktiv', 'NetzfahrplanV4RegeltestErlaubt',
'NetzfahrplanV4AktivtestErlaubt', 'NetzfahrplanV4WatchdogVerzichtErlaubt',
'PrognoseNetzfahrplanStart', 'PrognoseNetzfahrplanStop', 'PrognoseNetzfahrplanPruefen'] as $name) {
check(!isset($f[$name]), 'legacy or technical operator field removed: ' . $name);
}
$buttons = array_filter($f, static fn(array $field): bool => $field['type'] === 'Button');
check(array_keys($buttons) === ['PrognoseLizenzVerwalten'], 'only the portal link remains as a button');
check($buttons['PrognoseLizenzVerwalten']['link'] === true, 'portal button is a link');
check(str_contains($buttons['PrognoseLizenzVerwalten']['onClick'], 'https://license.enelix.ch'), 'existing license portal retained');
check(!str_contains($buttons['PrognoseLizenzVerwalten']['onClick'], 'IPS_RequestAction'), 'portal link cannot dispatch a command');
check($f['PrognoseNetzfahrplanStatus']['caption'] === 'Bereit: Lokale Pruefungen bestanden.', 'status displays current readiness and reason');
foreach ($base['elements'] as $index => $element) {
if (($element['name'] ?? '') !== 'PrognoseForecast') {
check($form['elements'][$index] === $element, 'unrelated panel unchanged: ' . $element['caption']);
check($form['elements'][$index] === $element, 'unrelated panel unchanged: ' . ($element['caption'] ?? $index));
}
}
foreach (['PrognoseAktiv', 'PrognoseSendeintervall', 'PrognoseLizenzInformation'] as $name) {
check($f[$name] === fields($base)[$name], 'existing forecast field unchanged: ' . $name);
foreach ([false, true] as $learning) {
foreach ([false, true] as $licensed) {
foreach ([false, true] as $ready) {
$changed = array_replace($state, ['prognoseAktiv' => $learning, 'lizenziert' => $licensed, 'ready' => $ready]);
$expected = $learning && $licensed && $ready;
check(ManagerPrognoseFormular::startErlaubt($changed) === $expected, 'all three start guards are required');
check(fields(ManagerPrognoseFormular::integrieren($base, $changed))['NetzfahrplanRegelungAktiv']['enabled'] === $expected,
'inactive control request follows start guard');
$changed['controlRequested'] = true;
check(fields(ManagerPrognoseFormular::integrieren($base, $changed))['NetzfahrplanRegelungAktiv']['enabled'] === true,
'requested control can be switched off despite any failed guard');
}
}
}
check($f['NetzfahrplanAktiv']['visible'] === false, 'inactive legacy control hidden for configured V4');
check($f['PrognoseNetzfahrplanStart']['enabled'], 'licensed existing consent enables explicit start');
check(str_contains($f['PrognoseNetzfahrplanStart']['onClick'], "'FormNetzfahrplanSchalten', true"), 'start uses guarded manager action');
check(str_contains($f['PrognoseNetzfahrplanStop']['onClick'], "'FormNetzfahrplanSchalten', false"), 'stop uses existing stop path');
check(str_contains($f['PrognoseNetzfahrplanPruefen']['onClick'], "'NetzfahrplanV4Empfangen'"), 'plan check uses existing throttled receiver');
check(str_contains($f['PrognoseLizenzVerwalten']['onClick'], 'https://license.enelix.ch'), 'existing license portal retained');
check(str_contains($f['PrognoseNetzfahrplanStatus']['caption'], '07.10.2026 15:38:07 UTC'), 'original expiry displayed without extension');
check(str_contains($f['PrognoseNetzfahrplanStatus']['caption'], 'Kein freigegebener Dauerbetrieb'), 'test limitation remains explicit');
foreach (['prognoseAktiv', 'lizenziert', 'senderAktiv', 'empfangAktiv', 'managerAktiv',
'testErlaubt', 'aktivtestErlaubt', 'watchdogVerzichtErlaubt'] as $key) {
$blocked = array_replace($state, [$key => false]);
$b = fields(ManagerPrognoseFormular::integrieren($base, $blocked));
check(!$b['PrognoseNetzfahrplanStart']['enabled'], 'start blocked without ' . $key);
check($b['PrognoseNetzfahrplanStop']['enabled'], 'stop remains possible without ' . $key);
}
foreach ([['legacyAktiv' => true], ['aktivtestAktiv' => true], ['ende' => 1791300000], ['ende' => -1]] as $change) {
check(!ManagerPrognoseFormular::startErlaubt(array_replace($state, $change)), 'conflict, replay or expiry blocks start: ' . json_encode($change));
foreach (['prognoseAktiv', 'lizenziert', 'ready'] as $key) {
foreach ([null, 0, 1, 'true', 'false', []] as $invalid) {
check(!ManagerPrognoseFormular::startErlaubt(array_replace($state, [$key => $invalid])), 'nonboolean guard fails closed: ' . $key);
}
$missing = $state;
unset($missing[$key]);
check(!ManagerPrognoseFormular::startErlaubt($missing), 'missing guard fails closed: ' . $key);
}
check(!ManagerPrognoseFormular::startErlaubt([]), 'missing state fails closed');
check(ManagerPrognoseFormular::startErlaubt(array_replace($state, ['ende' => 0])), 'first start still uses existing backend consent procedure');
$b = fields(ManagerPrognoseFormular::integrieren($base, array_replace($state, ['legacyAktiv' => true, 'lizenziert' => false])));
check($b['NetzfahrplanAktiv']['visible'] && $b['NetzfahrplanAktiv']['enabled'], 'active legacy controller can be disabled despite lost license');
check(str_contains($b['PrognoseNetzfahrplanStatus']['caption'], 'konkurrier') === false
&& str_contains($b['PrognoseNetzfahrplanStatus']['caption'], 'gesperrt'), 'controller conflict is visible');
$b = fields(ManagerPrognoseFormular::integrieren($base, array_replace($state, ['lizenziert' => false])));
check($b['NetzfahrplanV4EmpfangAktiv']['enabled'], 'active receiver remains switchable off without license');
$b = fields(ManagerPrognoseFormular::integrieren($base, array_replace($state, ['lizenziert' => false, 'empfangAktiv' => false])));
check(!$b['NetzfahrplanV4EmpfangAktiv']['enabled'], 'unlicensed receiver cannot be enabled through form');
$b = fields(ManagerPrognoseFormular::integrieren($base, array_replace($state, [
'senderAktiv' => false, 'empfangAktiv' => false, 'messdatenAktiv' => false,
])));
check($b['NetzfahrplanAktiv'] === fields($base)['NetzfahrplanAktiv'], 'non-migrated installation retains legacy control');
foreach (['disabled' => 'Aus', 'collecting' => 'Daten sammeln', 'learning' => 'Lernen',
'ready' => 'Bereit', 'active' => 'Aktiv', 'interrupted' => 'Unterbrochen'] as $key => $label) {
check(ManagerPrognoseFormular::statustext(['state' => $key, 'reason' => 'Pruefgrund']) === $label . ': Pruefgrund',
'workflow state and reason visible: ' . $key);
}
check(str_starts_with(ManagerPrognoseFormular::statustext(['state' => 'future-state']), 'Status unbekannt:'), 'unknown workflow status is not reported as ready');
check(str_contains(ManagerPrognoseFormular::statustext([]), 'Aktuellen Status abwarten.'), 'missing reason has a waiting state');
$missing = $base;
$missing['elements'] = array_values(array_filter($missing['elements'], fn($e) => ($e['name'] ?? '') !== 'PrognoseForecast'));
$missing['elements'] = array_values(array_filter($missing['elements'], static fn(array $e): bool => ($e['name'] ?? '') !== 'PrognoseForecast'));
try {
ManagerPrognoseFormular::integrieren($missing, $state);
check(false, 'missing forecast anchor rejected');
@@ -123,5 +140,6 @@ try {
} catch (LogicException $e) {
check(true, 'duplicate forecast anchor rejected');
}
check($form === ManagerPrognoseFormular::integrieren($base, $state), 'repeated rendering is deterministic and does not modify the base form');
check($form === ManagerPrognoseFormular::integrieren($base, $state), 'repeated rendering is deterministic');
check($base === $original, 'base form and historical configuration are not mutated');
echo "Total: $count checks. Form generation only; no IPS or device calls.\n";
+48 -44
View File
@@ -26,14 +26,10 @@ require_once __DIR__ . '/../../Manager/module.php';
final class ManagerFormHarness extends Manager
{
public array $calls = [];
public bool $reject = false;
public function V4ManagerAktivtestSchalten(bool $aktiv): void
{
$this->calls[] = $aktiv;
if ($this->reject) {
throw new RuntimeException('Existing backend refused start');
}
$this->values['NetzfahrplanV4Aktivtest'] = $aktiv;
throw new RuntimeException('The normal form must never invoke the legacy trial controller.');
}
}
@@ -53,6 +49,7 @@ function fixture(): ManagerFormHarness
'NetzfahrplanV4EmpfangAktiv', 'NetzfahrplanV4MessdatenAktiv', 'NetzfahrplanV4RegeltestErlaubt',
'NetzfahrplanV4AktivtestErlaubt', 'NetzfahrplanV4WatchdogVerzichtErlaubt'], true);
$h->props['NetzfahrplanAktiv'] = false;
$h->props['NetzfahrplanRegelungAktiv'] = false;
// Synthetic fixture, not a license or credential from an installation.
$h->props['Lizenzcode'] = 'ENX' . str_repeat('A', 16);
$h->attrs['LizenzcodeHash'] = hash('sha256', $h->props['Lizenzcode']);
@@ -67,6 +64,8 @@ function fixture(): ManagerFormHarness
],
], JSON_THROW_ON_ERROR);
$h->attrs['NetzfahrplanV4AktivtestEnde'] = time() + 600;
$h->attrs['PrognoseV4Serverstatus'] = json_encode(['receivedAt' => time(),
'workflow' => ['planningReady' => false, 'state' => 'collecting', 'reason' => 'Messdaten werden gesammelt.']], JSON_THROW_ON_ERROR);
$h->values = ['Aktiv' => true, 'NetzfahrplanV4Aktivtest' => false];
$GLOBALS['instanceStatus'] = 102;
return $h;
@@ -74,58 +73,63 @@ function fixture(): ManagerFormHarness
function rejected(ManagerFormHarness $h, $value, string $name): void
{
$before = [$h->props, $h->attrs, $h->values];
try {
$h->RequestAction('FormNetzfahrplanSchalten', $value);
} catch (RuntimeException | InvalidArgumentException $e) {
check($h->calls === [], $name);
check($h->calls === [] && [$h->props, $h->attrs, $h->values] === $before, $name);
return;
}
check(false, $name);
}
rejected(fixture(), true, 'old test permissions cannot bypass missing workflow readiness');
foreach ([1, 0, 'true', 'false', null, []] as $value) {
rejected(fixture(), $value, 'nonboolean compatibility action rejected: ' . gettype($value));
}
$h = fixture();
$before = [$h->props, $h->attrs];
$h->RequestAction('FormNetzfahrplanSchalten', true);
check($h->calls === [true], 'real manager action delegates exactly one explicit start');
check(!$h->fields['PrognoseNetzfahrplanStart']['enabled'], 'started session cannot be restarted from form');
check([$h->props, $h->attrs] === $before, 'form action changes no configuration, expiry or license');
$h->props['Lizenzcode'] = '';
$before = [$h->props, $h->attrs, $h->values];
$h->RequestAction('FormNetzfahrplanSchalten', false);
check($h->calls === [true, false], 'stop still delegates after license loss');
check(!$h->values['NetzfahrplanV4Aktivtest'], 'stop status is refreshed');
foreach ([1, 'true', 'false', null, []] as $value) {
rejected(fixture(), $value, 'nonboolean input rejected: ' . gettype($value));
}
foreach (['PrognoseAktiv', 'NetzfahrplanV4SchattenAktiv', 'NetzfahrplanV4EmpfangAktiv',
'NetzfahrplanV4RegeltestErlaubt', 'NetzfahrplanV4AktivtestErlaubt', 'NetzfahrplanV4WatchdogVerzichtErlaubt'] as $prop) {
$h = fixture(); $h->props[$prop] = false;
rejected($h, true, 'server-side start guard checks ' . $prop);
}
check($h->fields['NetzfahrplanRegelungAktiv']['value'] === false, 'compatibility disable updates the saved-property form field');
check($h->calls === [], 'compatibility form action invokes no legacy trial controller');
check([$h->props, $h->attrs, $h->values] === $before, 'form preview changes no properties, authority or device values');
$h = fixture(); $h->props['Lizenzcode'] = '';
rejected($h, true, 'missing license cannot be bypassed by calling the action directly');
rejected($h, true, 'missing license cannot be bypassed through the old action');
$h->RequestAction('FormNetzfahrplanSchalten', false);
check($h->fields['NetzfahrplanRegelungAktiv']['value'] === false && $h->calls === [], 'disable preview remains possible after license loss');
$h = fixture(); $lease = json_decode($h->attrs['LizenzLease'], true);
$lease['entitlements'][1]['quantity'] = 0; $h->attrs['LizenzLease'] = json_encode($lease);
rejected($h, true, 'manager license alone does not grant grid schedule');
$h = fixture(); $h->props['NetzfahrplanAktiv'] = true;
rejected($h, true, 'competing legacy controller blocks action');
$h = fixture(); $h->attrs['NetzfahrplanV4AktivtestEnde'] = time() - 1;
rejected($h, true, 'expired consent cannot be renewed by the form');
$h = fixture(); $GLOBALS['instanceStatus'] = 203;
rejected($h, true, 'inactive or faulty manager cannot start');
$h = fixture(); $h->values['Aktiv'] = false;
rejected($h, true, 'disabled manager cannot start');
$h = fixture(); $h->values['NetzfahrplanV4Aktivtest'] = true;
rejected($h, true, 'existing session cannot be restarted');
$h = fixture(); $h->reject = true;
try {
$h->RequestAction('FormNetzfahrplanSchalten', true);
check(false, 'backend rejection preserved');
} catch (RuntimeException $e) {
check($e->getMessage() === 'Existing backend refused start' && $h->calls === [true], 'backend rejection preserved without retry or bypass');
}
$h = fixture(); $h->RequestAction('FormPrognoseAktiv', false);
check(!$h->fields['PrognoseNetzfahrplanStart']['enabled'] && $h->calls === [], 'unsaved forecast disable prevents start without a control call');
$h = fixture(); $h->props['PrognoseAktiv'] = false;
rejected($h, true, 'stored learning consent is required');
$h = fixture(); $h->attrs['PrognoseV4Serverstatus'] = '{}';
rejected($h, true, 'missing server workflow is not ready');
$h = fixture(); $h->attrs['PrognoseV4Serverstatus'] = json_encode(['receivedAt' => time() - 100,
'workflow' => ['planningReady' => true]], JSON_THROW_ON_ERROR);
rejected($h, true, 'stale server readiness cannot start control');
$h = fixture(); $h->attrs['PrognoseV4Einrichtungsfehler'] = 'Batteriezuordnung unvollstaendig.';
rejected($h, true, 'setup errors block the compatibility start action');
$h = fixture();
$h->RequestAction('FormPrognoseAktiv', false);
check(!$h->fields['NetzfahrplanRegelungAktiv']['enabled'] && $h->calls === [], 'unsaved learning disable prevents a new control request without dispatch');
check($h->fields['NetzfahrplanRegelungAktiv']['value'] === false, 'learning disable clears the form control request');
$h = fixture(); $h->props['PrognoseAktiv'] = false;
$h->RequestAction('FormPrognoseAktiv', true);
check(!$h->fields['PrognoseNetzfahrplanStart']['enabled'] && $h->calls === [], 'unsaved forecast enable does not grant stored permission');
echo "Total: $count checks. Real RequestAction and license checks; mocked IPS and control delegate.\n";
check(!$h->fields['NetzfahrplanRegelungAktiv']['enabled'] && $h->calls === [], 'unsaved learning enable does not grant stored readiness');
foreach (['license', 'setup', 'learning'] as $failure) {
$h = fixture(); $h->props['NetzfahrplanRegelungAktiv'] = true;
if ($failure === 'license') $h->props['Lizenzcode'] = '';
if ($failure === 'setup') $h->attrs['PrognoseV4Einrichtungsfehler'] = 'Zuordnung fehlt.';
if ($failure === 'learning') $h->props['PrognoseAktiv'] = false;
$h->RequestAction('FormPrognoseAktiv', false);
check($h->fields['NetzfahrplanRegelungAktiv']['enabled'] === true && $h->calls === [],
'already requested control remains switchable off after ' . $failure . ' failure');
}
$h = fixture(); $h->RequestAction('FormPrognoseAktiv', true);
check(str_contains($h->fields['PrognoseNetzfahrplanStatus']['caption'], 'Messdaten werden gesammelt.'), 'manager form displays the current workflow reason');
foreach (['PrognoseNetzfahrplanStart', 'PrognoseNetzfahrplanStop', 'PrognoseNetzfahrplanPruefen', 'NetzfahrplanV4EmpfangAktiv'] as $name) {
check(!isset($h->fields[$name]), 'form refresh no longer updates removed field: ' . $name);
}
echo "Total: $count checks. Real RequestAction and license checks; mocked IPS, no device calls.\n";
+282
View File
@@ -0,0 +1,282 @@
<?php
declare(strict_types=1);
namespace Belevo\EnelixEMS;
use RuntimeException;
use Throwable;
require_once __DIR__ . '/../../libs/ManagerPrognoseWorkflowTrait.php';
require_once __DIR__ . '/../../libs/ManagerNetzfahrplanV4DatenTrait.php';
require_once __DIR__ . '/../../libs/Lizenzpruefung.php';
require_once __DIR__ . '/../V4Receiver/fixture.php';
function time(): int { return $GLOBALS['workflowTime']; }
function gmdate(string $format, ?int $at = null): string { return \gmdate($format, $at ?? time()); }
function IPS_SemaphoreEnter(string $name, int $wait): bool { return true; }
function IPS_SemaphoreLeave(string $name): void { $GLOBALS['workflowUnlocks'][] = $name; }
function IPS_VariableExists(int $id): bool { return isset($GLOBALS['workflowObjects'][$id]); }
function IPS_GetObject(int $id): array { return $GLOBALS['workflowObjects'][$id]; }
function IPS_GetVariable(int $id): array { return ['VariableType' => 2]; }
function IPS_InstanceExists(int $id): bool { return isset($GLOBALS['workflowInstances'][$id]); }
function IPS_GetInstance(int $id): array { return $GLOBALS['workflowInstances'][$id]; }
function IPS_GetProperty(int $id, string $key) { return $GLOBALS['workflowProperties'][$id][$key] ?? null; }
function IPS_RequestAction(int $id, string $action, $value): bool
{
$GLOBALS['workflowActions'][] = ['id' => $id, 'action' => $action, 'value' => json_decode($value, true, 64, JSON_THROW_ON_ERROR)];
return !in_array($id, $GLOBALS['workflowReject'] ?? [], true);
}
final class WorkflowHarness
{
use ManagerPrognoseWorkflowTrait;
use ManagerNetzfahrplanV4DatenTrait;
public int $InstanceID = 900;
public array $properties = [];
public array $attributes = [];
public array $values = ['Aktiv' => true, 'NetzfahrplanV4Aktivtest' => false];
public array $timers = [];
public array $requests = [];
public array $session = [];
public array $operation = [];
public array $actual = [];
public bool $licensed = true;
public ?string $httpError = null;
public ?string $selectedDataset = null;
public int $uiUpdates = 0;
public function __construct()
{
$this->registrierePrognoseWorkflow();
$this->registriereNetzfahrplanV4Daten();
[$response, $id, $operation, $now] = \receiverFixture();
$GLOBALS['workflowTime'] = $now;
$id = '11111111-2222-4333-8444-555555555555';
$response['installationId'] = $response['plan']['installationId'] = $id;
$response['plan']['inputQuality'] = ['loadBasis' => 'base_load', 'accountingEvidenceId' => 'physical-map-v1'];
$this->properties += ['PrognoseAktiv' => true, 'NetzleistungVariableID' => 101, 'Netzleistungsfaktor' => 1.0,
'MesswertMaxAlter' => 60, 'SDLAktiv' => false, 'SDLLeistungVariableID' => 0,
'AnlagenWechselrichter' => json_encode([
['ID' => 'pv-wr', 'Typ' => 'pv', 'IstleistungVariableID' => 102, 'Istleistungsfaktor' => 1.0],
['ID' => 'battery-wr', 'Typ' => 'battery'],
], JSON_THROW_ON_ERROR),
'AnlagenBatterien' => json_encode([['ID' => 'battery', 'WechselrichterID' => 'battery-wr',
'Kopplung' => 'ac', 'LeistungVariableID' => 103, 'Leistungsfaktor' => 1.0]], JSON_THROW_ON_ERROR)];
$this->attributes += ['LizenzInstallationID' => $id, 'PrognoseInstallationsToken' => ''];
$this->attributes['NetzfahrplanV4Empfang'] = json_encode(['response' => $response, 'receivedAt' => $now], JSON_THROW_ON_ERROR);
$operation['localFeedback'] = ['b' => [
'version' => 1, 'kind' => 'local_physical_feedback', 'installationId' => $id, 'managerId' => 900,
'assetId' => 'b', 'batteryInstanceId' => 901, 'checkedAt' => $now, 'sourceOldestAt' => $now,
'maxAgeSeconds' => 60, 'configHash' => str_repeat('a', 64), 'gridW' => $operation['gridW'],
'batteryW' => 0.0, 'estimated' => false, 'separatelyMeasured' => true,
'deviceReadConfirmed' => true, 'usableForTrial' => true,
]];
$this->operation = $operation;
$this->actual = ['b' => 0.0];
$GLOBALS['workflowObjects'] = [];
$GLOBALS['workflowInstances'] = [
900 => ['InstanceStatus' => 102, 'ModuleInfo' => ['ModuleID' => 'manager-module']],
901 => ['InstanceStatus' => 102, 'ModuleInfo' => ['ModuleID' => 'battery-module']],
902 => ['InstanceStatus' => 102, 'ModuleInfo' => ['ModuleID' => 'charger-module']],
];
foreach ([101 => 'Grid', 102 => 'PV', 103 => 'Battery'] as $source => $ident) {
$GLOBALS['workflowObjects'][$source] = ['ParentID' => $source + 100, 'ObjectIdent' => $ident, 'ObjectType' => 2];
$GLOBALS['workflowInstances'][$source + 100] = ['InstanceStatus' => 102,
'ModuleInfo' => ['ModuleID' => NetzfahrplanV4Geraeteabruf::MODBUS_DEVICE]];
}
$GLOBALS['workflowProperties'] = [901 => ['NetzfahrplanV4GeraeteWatchdogNachweis' => 'device-watchdog-reviewed-v1']];
$GLOBALS['workflowActions'] = [];
$GLOBALS['workflowReject'] = [];
}
public function RegisterPropertyBoolean(string $name, bool $value): void { $this->properties[$name] ??= $value; }
public function RegisterPropertyString(string $name, string $value): void { $this->properties[$name] ??= $value; }
public function RegisterAttributeString(string $name, string $value): void { $this->attributes[$name] ??= $value; }
public function RegisterAttributeInteger(string $name, int $value): void { $this->attributes[$name] ??= $value; }
public function RegisterTimer(string $name, int $value, string $script): void { $this->timers[$name] = $value; }
public function RegisterVariableString(string $name, string $caption, string $profile, int $position): void {}
public function ReadPropertyBoolean(string $name): bool { return $this->properties[$name] ?? false; }
public function ReadPropertyString(string $name): string { return $this->properties[$name] ?? ''; }
public function ReadPropertyInteger(string $name): int { return $this->properties[$name] ?? 0; }
public function ReadPropertyFloat(string $name): float { return $this->properties[$name] ?? 0.0; }
public function ReadAttributeString(string $name): string { return $this->attributes[$name] ?? ''; }
public function ReadAttributeInteger(string $name): int { return $this->attributes[$name] ?? 0; }
public function WriteAttributeString(string $name, string $value): void { $this->attributes[$name] = $value; }
public function WriteAttributeInteger(string $name, int $value): void { $this->attributes[$name] = $value; }
public function SetTimerInterval(string $name, int $value): void { $this->timers[$name] = $value; }
public function GetValue(string $name) { return $this->values[$name] ?? false; }
public function SetValue(string $name, $value): void { $this->values[$name] = $value; }
private function leseJsonAttribut(string $name): array { return json_decode($this->ReadAttributeString($name) ?: '{}', true, 64, JSON_THROW_ON_ERROR); }
private function berechtigungLizenziert(string $capability): bool { return $this->licensed; }
private function aktiveVerbraucherIDs(): array { return [901, 902]; }
private function v4ManagerTestSession(): array { return $this->session; }
private function netzfahrplanV4LokaleVorschauwerte(): array { return [$this->operation, $this->actual]; }
private function aktualisierePrognoseBedienung(): void { ++$this->uiUpdates; }
private function prognoseWorkflowHttp(string $suffix, array $body): array
{
$this->requests[] = ['suffix' => $suffix, 'body' => $body];
if ($this->httpError !== null) throw new RuntimeException($this->httpError);
if ($suffix === '/setup') {
return ['datasetId' => $body['dataset']['datasetId'], 'selectedDatasetId' => $this->selectedDataset ?? $body['dataset']['datasetId']];
}
return ['status' => 'recorded'];
}
public function server(bool $ready, string $state = 'ready', ?int $at = null): void
{
$this->attributes['PrognoseV4Serverstatus'] = json_encode(['receivedAt' => $at ?? time(),
'workflow' => ['planningReady' => $ready, 'state' => $state, 'reason' => 'server-' . $state]], JSON_THROW_ON_ERROR);
}
public function state(): array { return $this->prognoseWorkflowZustand(); }
public function prepare(): void { $this->vorbereitePrognoseWorkflow(); }
public function tick(): void { $this->aktualisierePrognoseWorkflow(); }
public function dataset(): string { return $this->prognoseV4Datensatz(); }
public function mapping(): string { return $this->prognoseV4Messkonfiguration(); }
public function correct(array &$allocation): ?array { return $this->korrigierePrognoseBatterie($allocation, [], true); }
}
$count = 0;
function workflowCheck(bool $ok, string $label): void
{
global $count;
if (!$ok) throw new RuntimeException('FAIL ' . $label);
++$count;
echo 'PASS ' . $label . "\n";
}
$h = new WorkflowHarness();
$h->server(false, 'collecting');
workflowCheck($h->state()['state'] === 'collecting' && !$h->state()['ready'], 'model not ready does not enable control');
$h->properties['NetzfahrplanRegelungAktiv'] = true;
$a = ['Sollwerte' => [901 => 1200, 902 => 5000]]; $old = $a;
workflowCheck($h->correct($a) === null && $a === $old, 'persisted control request cannot bypass missing model');
$h->properties['NetzfahrplanAktiv'] = true;
$h->properties['NetzfahrplanV4RegeltestErlaubt'] = true;
$h->properties['NetzfahrplanV4AktivtestErlaubt'] = true;
workflowCheck(!$h->state()['ready'] && !$h->state()['controlActive'], 'old flags do not promote an unready new workflow');
$h = new WorkflowHarness(); $h->server(false, 'learning');
workflowCheck($h->state()['state'] === 'learning', 'learning state remains visible before readiness');
$h = new WorkflowHarness(); $h->server(true);
workflowCheck($h->state()['state'] === 'ready' && $h->state()['ready'] && !$h->state()['controlActive'], 'ready model and physical guards still do not activate control');
$h->properties['NetzfahrplanRegelungAktiv'] = true;
$h->attributes['PrognoseV4Regelstatus'] = json_encode(['controlActive' => true, 'at' => time()], JSON_THROW_ON_ERROR);
workflowCheck($h->state()['state'] === 'active' && $h->state()['controlActive'], 'fresh local delivery status required for active state');
$h->attributes['PrognoseV4Regelstatus'] = json_encode(['controlActive' => true, 'at' => time() - 11], JSON_THROW_ON_ERROR);
workflowCheck($h->state()['state'] === 'interrupted' && !$h->state()['controlActive'], 'stale active status expires');
$h->server(true, 'ready', time() - 91);
workflowCheck(!$h->state()['ready'] && !$h->state()['controlActive'], 'stale server status blocks activation');
$h->server(true, 'ready', time() + 1);
workflowCheck(!$h->state()['ready'], 'future server status blocked');
$h = new WorkflowHarness(); $h->server(true); $h->licensed = false;
workflowCheck($h->state()['state'] === 'interrupted' && !$h->state()['ready'], 'missing licence blocks readiness');
$h->tick();
workflowCheck($h->requests === [] && $h->uiUpdates === 1, 'no unlicensed server traffic and visible UI refresh');
$h = new WorkflowHarness(); $h->server(true); $h->properties['PrognoseAktiv'] = false;
$h->properties['NetzfahrplanRegelungAktiv'] = true;
workflowCheck($h->state()['state'] === 'disabled' && !$h->state()['ready'], 'learning switch off prevents control');
$h->tick();
workflowCheck(count($h->requests) === 1 && $h->requests[0]['suffix'] === '/workflow'
&& !$h->requests[0]['body']['learningEnabled'] && !$h->requests[0]['body']['controlActive'], 'learning off is reported without setup or activation');
$h = new WorkflowHarness(); $h->server(true); $h->values['Aktiv'] = false;
workflowCheck(!$h->state()['ready'], 'inactive manager blocks readiness');
$h = new WorkflowHarness(); $h->server(true); $h->session = ['sessionId' => 'existing'];
workflowCheck(!$h->state()['ready'], 'separate commissioning session cannot run alongside ordinary control');
$h = new WorkflowHarness(); $h->server(true); $h->values['NetzfahrplanV4Aktivtest'] = true;
workflowCheck(!$h->state()['ready'], 'old active test is not ordinary control permission');
$h = new WorkflowHarness(); $h->server(true);
$GLOBALS['workflowProperties'][901]['NetzfahrplanV4GeraeteWatchdogNachweis'] = 'explicit_test_plant_watchdog_waiver';
workflowCheck(!$h->state()['ready'], 'historical watchdog waiver cannot activate ordinary control');
$h = new WorkflowHarness(); $h->server(true);
$h->operation['localFeedback']['b']['checkedAt'] -= 3;
workflowCheck(!$h->state()['ready'], 'stale physical feedback blocks ready despite healthy server');
$h = new WorkflowHarness(); $h->server(true);
$h->attributes['PrognoseV4Einrichtungsfehler'] = 'mapping failed';
workflowCheck($h->state()['reason'] === 'mapping failed' && !$h->state()['ready'], 'setup error cannot preserve readiness');
$h = new WorkflowHarness(); $h->server(true); $h->httpError = 'synthetic HTTP failure';
$h->tick();
workflowCheck($h->state()['state'] === 'interrupted' && !$h->state()['ready'], 'connection failure prevents active state');
$h->httpError = null; $h->tick();
workflowCheck($h->attributes['PrognoseV4Verbindungsfehler'] === '' && $h->state()['ready'], 'successful report recovers connection status');
$h = new WorkflowHarness(); $h->prepare();
workflowCheck($h->attributes['PrognoseV4Einrichtungsfehler'] === '' && str_starts_with($h->dataset(), 'v4-'), 'ordinary metered plant automatically configured');
$automatic = $h->attributes['PrognoseV4Einrichtung'];
$h->tick();
workflowCheck(array_column($h->requests, 'suffix') === ['/setup', '/workflow'], 'automatic dataset registered before workflow report');
$h->requests = []; $h->tick();
workflowCheck(array_column($h->requests, 'suffix') === ['/workflow'], 'unchanged setup throttled');
$h->properties['Netzleistungsfaktor'] = 1000.0; $h->prepare();
workflowCheck($h->attributes['PrognoseV4Einrichtung'] === $automatic && $h->attributes['PrognoseV4Einrichtungsfehler'] !== '', 'changed units hold while original setup remains preserved');
$h = new WorkflowHarness();
$GLOBALS['workflowInstances'][203]['ModuleInfo']['ModuleID'] = 'foreign-virtual-storage-module';
$h->prepare();
workflowCheck($h->attributes['PrognoseV4Einrichtungsfehler'] !== '' && $h->attributes['PrognoseV4Einrichtung'] === '{}', 'foreign library is not physical measurement evidence');
$h = new WorkflowHarness(); $h->prepare();
$automatic = $h->attributes['PrognoseV4Einrichtung'];
$mapping = $h->mapping();
$h->properties['NetzfahrplanV4Messkonfiguration'] = $mapping;
$h->properties['NetzfahrplanV4Datensatz'] = 'specialist-existing-v2';
$h->prepare(); $h->tick();
workflowCheck($h->mapping() === $mapping && $h->dataset() === 'specialist-existing-v2', 'custom mapping and dataset retained byte for byte');
workflowCheck($h->attributes['PrognoseV4Einrichtung'] === $automatic, 'historical automatic setup preserved for diagnosis');
workflowCheck(array_column($h->requests, 'suffix') === ['/workflow'], 'custom mapping never re-registers old automatic dataset');
$h->properties['NetzfahrplanV4Datensatz'] = ''; $h->prepare();
workflowCheck($h->attributes['PrognoseV4Einrichtungsfehler'] !== '', 'custom mapping without explicit dataset cannot reuse old automatic identity');
$h = new WorkflowHarness(); $h->prepare(); $h->selectedDataset = 'existing-server-choice'; $h->tick();
workflowCheck($h->attributes['PrognoseV4Verbindungsfehler'] !== '' && count($h->requests) === 1, 'server dataset choice is never silently overwritten or reported ready');
// Execute the actual Manager send block and sender method in a fake-only harness.
$module = file_get_contents(__DIR__ . '/../../Manager/module.php');
$start = strpos($module, ' $this->v4ManagerTestUebergabe($ergebnis, $verbraucher, $synchronisiert);');
$end = strpos($module, ' $this->aktualisiereStoerungen($verbraucher, $stoerungen);', $start);
$methodStart = strpos($module, ' private function sendeManagerdaten(');
$methodEnd = strpos($module, "\n /**", $methodStart);
if ($start === false || $end === false || $methodStart === false || $methodEnd === false) throw new RuntimeException('Manager source boundary changed');
$block = substr($module, $start, $end - $start);
$method = substr($module, $methodStart, $methodEnd - $methodStart);
class WorkflowSendBase
{
public int $InstanceID = 900;
public array $attributes = ['LetzteSollwerte' => '{}'];
public array $logs = [];
protected function v4ManagerTestUebergabe(array &$allocation, array $consumers, bool $sync): void {}
protected function korrigierePrognoseBatterie(array &$allocation, array $consumers, bool $sync): ?array
{
$allocation['Sollwerte'][901] = 2000;
return ['batteryInstanceId' => 901, 'targetW' => 2000, 'physicalResponseVerified' => false];
}
protected function leseJsonAttribut(string $name): array { return json_decode($this->attributes[$name], true, 64, JSON_THROW_ON_ERROR); }
protected function WriteAttributeString(string $name, string $value): void { $this->attributes[$name] = $value; }
protected function v4ManagerTestAbbrechen(string $reason): void {}
protected function protokolliere(string $kind, array $data): void { $this->logs[] = $data; }
}
eval('namespace Belevo\\EnelixEMS; use RuntimeException; use Throwable; class WorkflowSendHarness extends WorkflowSendBase {'
. 'public function run(array $ergebnis): array { $verbraucher=[]; $synchronisiert=true; $erneuern=false;'
. '$betriebsart="PV"; $stoerungen=[];' . $block . 'return $ergebnis; }' . $method . '}');
$setup = new WorkflowHarness();
$sender = new WorkflowSendHarness();
$sender->run(['Sollwerte' => [901 => 1500, 902 => 5000]]);
workflowCheck(array_column($GLOBALS['workflowActions'], 'id') === [902, 901], 'other commands sent before corrected battery');
workflowCheck($GLOBALS['workflowActions'][1]['value']['Sollleistung_W'] === 2000, 'successful handoff uses corrected battery target');
workflowCheck(json_decode($sender->attributes['PrognoseV4Regelstatus'], true)['controlActive'] === true, 'active only after successful sends');
$GLOBALS['workflowActions'] = []; $GLOBALS['workflowReject'] = [902];
$sender = new WorkflowSendHarness();
$sender->run(['Sollwerte' => [901 => 1500, 902 => 5000]]);
$report = json_decode($sender->attributes['PrognoseV4Regelstatus'], true);
workflowCheck(array_column($GLOBALS['workflowActions'], 'id') === [902, 901]
&& $GLOBALS['workflowActions'][1]['value']['Sollleistung_W'] === 1500, 'failed other command falls back to original ordinary battery target');
workflowCheck(!$report['controlActive'] && $report['fallbackTargetW'] === 1500, 'fallback is not reported as active V4');
workflowCheck(!array_key_exists(902, json_decode($sender->attributes['LetzteSollwerte'], true)), 'false device return does not advance accepted target');
workflowCheck(count($sender->logs) === 1 && $sender->logs[0]['InstanzID'] === 901, 'rejected handoff is not logged as successful');
$GLOBALS['workflowActions'] = []; $GLOBALS['workflowReject'] = [901];
$sender = new WorkflowSendHarness();
$sender->run(['Sollwerte' => [901 => 1500, 902 => 5000]]);
workflowCheck(!json_decode($sender->attributes['PrognoseV4Regelstatus'], true)['controlActive'], 'false battery return cannot claim active control');
workflowCheck(!array_key_exists(901, json_decode($sender->attributes['LetzteSollwerte'], true)), 'false battery return retains previous accepted target');
$GLOBALS['workflowActions'] = []; $GLOBALS['workflowReject'] = [];
$sender = new WorkflowSendHarness();
$sender->attributes['LetzteSollwerte'] = json_encode([901 => 2000, 902 => 5000], JSON_THROW_ON_ERROR);
$sender->run(['Sollwerte' => [901 => 1500, 902 => 5000]]);
workflowCheck(array_column($GLOBALS['workflowActions'], 'id') === [901], 'active battery target renewed even if value unchanged');
echo "TOTAL $count workflow checks passed; fake clocks, transport and Symcon calls only.\n";
+7 -4
View File
@@ -26,7 +26,7 @@ function curl_close($h):void {}
class Lizenzpruefung {const NETZFAHRPLAN='test-only';}
class FakeManager {
use ManagerNetzfahrplanV4DatenTrait;
public int $InstanceID=17004;public array $p=[],$a=[],$vars=[],$timers=[];public bool $licensed=true;
public int $InstanceID=17004;public array $p=['PrognoseAktiv'=>false],$a=['PrognoseV4Einrichtungsfehler'=>''],$vars=[],$timers=[];public bool $licensed=true;
public function boot():void {$this->registriereNetzfahrplanV4Daten();}
public function configure():void {$this->konfiguriereNetzfahrplanV4Daten();}
public function folder():string {return $this->v4DatenVerzeichnis();}
@@ -46,6 +46,9 @@ class FakeManager {
public function WriteAttributeInteger($k,$v){$this->a[$k]=$v;}
public function SetValue($k,$v){if($k!=='NetzfahrplanV4Datenstatus')throw new \RuntimeException('Unexpected variable write');$this->vars[$k]=$v;}
private function berechtigungLizenziert($k){return $this->licensed;}
private function prognoseLernenAktiv(){return $this->p['PrognoseAktiv'];}
private function prognoseV4Datensatz(){return $this->p['NetzfahrplanV4Datensatz'];}
private function prognoseV4Messkonfiguration(){return $this->p['NetzfahrplanV4Messkonfiguration'];}
}
$c=['version'=>1,'installationId'=>'e3a08f9e-af12-4695-99bd-8b51c0520021','managerId'=>17004,
'reportedInventorySha256'=>str_repeat('a',64),'accounting'=>['version'=>1,'maxSkewSeconds'=>30,'splitToleranceW'=>100.,'sources'=>[]],'extraSources'=>[]];
@@ -54,8 +57,8 @@ foreach(['grid','pv','physical_storage','controlled_virtual','external_virtual']
$sensor[$id]=['value'=>[3000,5000,2000,2000,0][$i],'updated'=>$clock,'changed'=>$clock];}
$m=new FakeManager();$m->boot();$m->a['LizenzInstallationID']=$c['installationId'];$m->a['PrognoseInstallationsToken']='synthetic-not-a-real-token';
try {
$m->ErfasseNetzfahrplanV4Daten();check($sent===[],'disabled by default makes no network request');
$m->p['NetzfahrplanV4MessdatenAktiv']=true;$m->p['NetzfahrplanV4Datensatz']='physical-v1';$m->p['NetzfahrplanV4Messkonfiguration']=json_encode($c);
$m->ErfasseNetzfahrplanV4Daten();check($sent===[],'learning disabled makes no network request');
$m->p['PrognoseAktiv']=true;$m->p['NetzfahrplanV4Datensatz']='physical-v1';$m->p['NetzfahrplanV4Messkonfiguration']=json_encode($c);
$m->configure();check($m->timers['NetzfahrplanV4DatenErfassen']===30000,'only application acquisition timer enabled');
$m->ErfasseNetzfahrplanV4Daten();check(count($sent)===1,'native capture delivered to existing device API');
$cursor=$m->a['NetzfahrplanV4DatenCursor'];check(json_decode($cursor,true)['offset']>0,'matching ack commits durable byte cursor');
@@ -86,7 +89,7 @@ try {
$err=json_decode($m->vars['NetzfahrplanV4Datenstatus'],true);
check($err['status']==='capture_error'&&$err['delivery']['status']==='acknowledged','capture failure does not deadlock pending delivery');
check(count($sent)===$beforeCount+1&&str_ends_with(file_get_contents($file),'crash-tail'),'complete prefix sent while original incomplete tail retained');
$m->p['NetzfahrplanV4MessdatenAktiv']=false;$m->configure();check($m->timers['NetzfahrplanV4DatenErfassen']===0,'disable affects acquisition only');
$m->p['PrognoseAktiv']=false;$m->configure();check($m->timers['NetzfahrplanV4DatenErfassen']===0,'learning switch disables acquisition without discarding history');
check(count($m->vars)===1,'only own manager data-status variable written');
$text=file_get_contents(__DIR__.'/../../libs/ManagerNetzfahrplanV4DatenTrait.php');
check(!preg_match('/\b(?:RequestAction\(|IPS_ApplyChanges\(|MC_Reload\(|schreibeRegister\()/',$text),'data path contains no actuator/module reload calls');
+206
View File
@@ -0,0 +1,206 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../../libs/NetzfahrplanV4Regelung.php';
require_once __DIR__ . '/../V4Receiver/fixture.php';
use Belevo\EnelixEMS\Nachrichtenvertrag as Message;
use Belevo\EnelixEMS\NetzfahrplanV4Regelung as Control;
use Belevo\EnelixEMS\NetzfahrplanV4Planpruefung as Plan;
function controlConsumer(int $id, int $manager, int $now, int $actual, array $offers): array
{
return ['InstanzID' => $id, 'Daten' => [
'Kopf' => ['Version' => Message::VERSION, 'AbsenderID' => $id, 'EmpfaengerID' => $manager, 'Zeitpunkt' => $now],
'Betriebsart' => Message::BETRIEBSART_PV, 'PrioritaetPV' => 1, 'PrioritaetPeak' => 1,
'Leistungswerte_W' => $offers, 'AenderungMoeglich' => true, 'Verfuegbar' => true,
'Istleistung_W' => $actual, 'Leistungsquelle' => Message::LEISTUNGSQUELLE_GEMESSEN,
'Zustand' => [['Kennung' => 'Sollleistung_W', 'Art' => 'Sollwert', 'Wert' => $actual, 'Einheit' => 'W']],
]];
}
function controlFixture(): array
{
[$response, $installation, $op, $now] = receiverFixture();
$manager = 900;
$battery = 901;
$binding = ['batteryInstanceId' => $battery, 'assetId' => 'b', 'watchdogEvidence' => 'device-watchdog-reviewed-v1',
'feedbackConfigHash' => str_repeat('a', 64)];
$response['plan']['inputQuality'] = ['loadBasis' => 'base_load', 'accountingEvidenceId' => 'physical-meter-map-v1'];
$feedback = ['version' => 1, 'kind' => 'local_physical_feedback', 'installationId' => $installation,
'assetId' => 'b', 'managerId' => $manager, 'batteryInstanceId' => $battery, 'checkedAt' => $now,
'sourceOldestAt' => $now, 'sourceNewestAt' => $now, 'physicalOldestAt' => $now,
'deviceReadConfirmed' => true, 'maxAgeSeconds' => 60, 'configHash' => str_repeat('a', 64),
'gridW' => $op['gridW'], 'physicalStorageW' => 0.0, 'batteryW' => 0.0,
'estimated' => false, 'separatelyMeasured' => true, 'usableForTrial' => true, 'canDispatch' => false];
$op['localFeedback'] = ['b' => $feedback];
return ['envelope' => ['response' => $response, 'receivedAt' => $now], 'installation' => $installation,
'manager' => $manager, 'operation' => $op, 'actual' => ['b' => 0.0],
'allocation' => ['Verteilbudget_W' => 6500, 'Restabweichung_W' => 0, 'Sollwerte' => [901 => 1500, 902 => 5000]],
'consumers' => [controlConsumer($battery, $manager, $now, 0, [['Von_W' => -39000, 'Bis_W' => 39000]]),
controlConsumer(902, $manager, $now, 4000, [['Von_W' => 0, 'Bis_W' => 11000]])],
'synchronized' => true, 'binding' => $binding, 'now' => $now];
}
function controlRun(array $f): array
{
return Control::korrigieren($f['envelope'], $f['installation'], $f['manager'], $f['operation'], $f['actual'],
$f['allocation'], $f['consumers'], $f['synchronized'], $f['binding'], $f['now']);
}
function controlReady(array $f): array
{
return Control::pruefen($f['envelope'], $f['installation'], $f['manager'], $f['operation'], $f['actual'], $f['binding'], $f['now']);
}
function controlContext(array &$f): void
{
$f['envelope']['response']['plan']['controlContext'] = Plan::kontext($f['operation']);
}
$count = 0;
function controlCheck(bool $ok, string $label): void
{
global $count;
if (!$ok) {
throw new RuntimeException('FAIL ' . $label);
}
++$count;
echo 'PASS ' . $label . "\n";
}
function controlRefuses(callable $call, string $label): void
{
try {
$call();
} catch (InvalidArgumentException $e) {
controlCheck(true, $label);
return;
}
throw new RuntimeException('Expected refusal: ' . $label);
}
$f = controlFixture();
$original = $f;
$r = controlRun($f);
controlCheck($r['allocation']['Sollwerte'][901] === 2000, 'battery follows grid target after other consumer delta');
controlCheck($r['allocation']['Sollwerte'][902] === 5000, 'other consumer target remains exact');
controlCheck($r['allocation']['Restabweichung_W'] === -500, 'allocation arithmetic reflects battery correction');
controlCheck($r['report']['expectedGridW'] === 0.0 && $r['report']['otherConsumerPredictedDeltaW'] === 1000.0, 'other consumer measured delta counted once');
controlCheck($r['report']['batteryInstanceId'] === 901 && $r['report']['assetId'] === 'b', 'report bound to selected battery');
controlCheck(!$r['report']['physicalResponseVerified'], 'calculation never claims physical response');
controlCheck($f === $original, 'inputs and source timestamps never changed');
$ready = controlReady($f);
controlCheck($ready['preview']['previewBatteryW'] === 3000.0 && !$ready['preview']['canDispatch'], 'readiness checks no actuator or other allocation');
controlCheck($ready['preview']['maxChargeW'] === 39000.0 && $ready['preview']['maxDischargeW'] === 39000.0, 'preview reports bounded charge/discharge envelope');
$x = $f; $x['synchronized'] = false;
controlRefuses(static fn() => controlRun($x), 'unsynchronized consumers blocked');
$x = $f; unset($x['operation']['localFeedback']);
controlRefuses(static fn() => controlReady($x), 'missing verified feedback blocks readiness');
foreach (['estimated' => true, 'separatelyMeasured' => false, 'deviceReadConfirmed' => false, 'usableForTrial' => false] as $key => $value) {
$x = $f; $x['operation']['localFeedback']['b'][$key] = $value;
controlRefuses(static fn() => controlRun($x), 'feedback gate ' . $key);
}
foreach (['checkedAt' => $f['now'] - 3, 'sourceOldestAt' => $f['now'] - 61, 'maxAgeSeconds' => 61] as $key => $value) {
$x = $f; $x['operation']['localFeedback']['b'][$key] = $value;
controlRefuses(static fn() => controlReady($x), 'stale or relaxed feedback gate ' . $key);
}
$x = $f; $x['operation']['localFeedback']['b']['checkedAt']++;
controlRefuses(static fn() => controlRun($x), 'future feedback rejected');
foreach (['managerId' => 800, 'batteryInstanceId' => 903, 'assetId' => 'wrong', 'installationId' => 'wrong'] as $key => $value) {
$x = $f; $x['operation']['localFeedback']['b'][$key] = $value;
controlRefuses(static fn() => controlRun($x), 'wrong feedback binding ' . $key);
}
$x = $f; $x['binding']['feedbackConfigHash'] = str_repeat('b', 64);
controlRefuses(static fn() => controlRun($x), 'changed source mapping blocked');
foreach (['', 'short', 'explicit_test_plant_watchdog_waiver', ' explicit_test_plant_watchdog_waiver '] as $value) {
$x = $f; $x['binding']['watchdogEvidence'] = $value;
controlRefuses(static fn() => controlReady($x), 'missing watchdog or test-only waiver is no production evidence');
}
$x = $f; unset($x['envelope']['response']['plan']['inputQuality']);
controlRefuses(static fn() => controlReady($x), 'missing physical accounting evidence rejected');
$x = $f; $x['envelope']['response']['plan']['inputQuality']['loadBasis'] = 'net_load';
controlRefuses(static fn() => controlRun($x), 'contaminated net-load forecast rejected');
$x = $f; $x['envelope']['receivedAt'] -= 91;
controlRefuses(static fn() => controlReady($x), 'expired plan receipt rejected');
$x = $f; $x['envelope']['response']['pending'] = 'new-plan';
controlRefuses(static fn() => controlRun($x), 'pending revision blocks control');
$x = $f; $x['operation']['batteries'][0]['measuredAt'] = gmdate('c', $f['now'] - 61);
controlRefuses(static fn() => controlReady($x), 'stale battery limits block readiness');
$x = $f; $x['operation']['batteries'][] = $x['operation']['batteries'][0];
controlRefuses(static fn() => controlRun($x), 'multiple battery topology blocked');
$x = $f; unset($x['allocation']['Sollwerte'][901]);
controlRefuses(static fn() => controlRun($x), 'battery absent from ordinary allocation blocked');
$x = $f; $x['consumers'][] = $x['consumers'][0];
controlRefuses(static fn() => controlRun($x), 'duplicate active battery entry blocked');
$x = $f; $x['actual']['b'] = 1000.0;
controlRefuses(static fn() => controlRun($x), 'battery actual must come from verified snapshot');
$x = $f; $x['operation']['gridW'] += 1000.0;
controlRefuses(static fn() => controlRun($x), 'pre-adjusted grid cannot double count predicted other load');
$x = $f; $x['operation']['localPredictedOtherDeltaW'] = 0.0;
controlRefuses(static fn() => controlRun($x), 'previous trial delta cannot leak into ordinary correction');
$x = $f; $x['consumers'][1]['Daten']['Leistungsquelle'] = Message::LEISTUNGSQUELLE_BERECHNET;
controlRefuses(static fn() => controlRun($x), 'other allocated consumers need measured actual');
$x = $f; $x['consumers'][1]['Daten']['Kopf']['Zeitpunkt'] -= 61;
controlRefuses(static fn() => controlRun($x), 'stale consumer message blocked despite supplied synchronization');
$x = $f; $x['allocation']['Sollwerte'][999] = 1000;
controlRefuses(static fn() => controlRun($x), 'unknown allocation target rejected');
$x = $f; $x['consumers'][0]['Daten']['AenderungMoeglich'] = false;
controlRefuses(static fn() => controlRun($x), 'battery lock respected');
$x = $f; $x['consumers'][0]['Daten']['Verfuegbar'] = false;
controlRefuses(static fn() => controlRun($x), 'unavailable battery blocked');
$x = $f; $x['consumers'][0]['Daten']['Leistungswerte_W'] = [];
controlRefuses(static fn() => controlRun($x), 'empty battery offer blocked');
$x = $f; $x['consumers'][0]['Daten']['Leistungswerte_W'] = [['Von_W' => -1000, 'Bis_W' => 1000]];
controlCheck(controlRun($x)['report']['targetW'] === 1000, 'clipping respects advertised battery range');
$x = $f; $x['consumers'][0]['Daten']['Leistungswerte_W'] = [0, 3000, ['Von_W' => 4000, 'Bis_W' => 5000]];
controlCheck(controlRun($x)['report']['targetW'] === 3000, 'discrete offer chosen instead of unavailable gap value');
$x = $f; $x['operation']['batteries'][0]['maxChargeW'] = 500.0;
controlCheck(controlRun($x)['report']['targetW'] === 500, 'current BMS limit restricts offer');
$x = $f; $x['operation']['batteries'][0]['socPercent'] = 90.0;
controlCheck(controlRun($x)['report']['targetW'] === 0, 'full battery cannot charge from target');
$x['consumers'][0]['Daten']['Leistungswerte_W'] = [['Von_W' => 1000, 'Bis_W' => 5000]];
controlRefuses(static fn() => controlRun($x), 'no safe offer cannot become unoffered zero');
$x = $f;
$x['actual']['b'] = 1000.0;
$x['operation']['localFeedback']['b']['batteryW'] = 1000.0;
$x['consumers'][0]['Daten']['Istleistung_W'] = 1000;
controlCheck(controlRun($x)['report']['targetW'] === 3000, 'already flowing battery power counted once');
$x = $f;
$x['operation']['gridW'] = 3000.0;
$x['operation']['localFeedback']['b']['gridW'] = 3000.0;
$x['operation']['batteries'][0]['socPercent'] = 15.0;
controlCheck(controlRun($x)['report']['targetW'] === 0, 'reserve blocks discharge although plan requests grid compensation');
$x = $f;
$x['operation']['gridW'] = 3000.0;
$x['operation']['localFeedback']['b']['gridW'] = 3000.0;
$x['operation']['batteries'][0]['dischargeBlocked'] = true;
controlCheck(controlRun($x)['report']['targetW'] === 0, 'local discharge hysteresis remains authoritative');
$x = $f;
$x['consumers'][] = controlConsumer(903, $x['manager'], $x['now'], 2000, [2000]);
$x['consumers'][2]['Daten']['Leistungsquelle'] = Message::LEISTUNGSQUELLE_BERECHNET;
controlCheck(controlRun($x)['report']['targetW'] === 2000, 'unallocated other consumers remain already counted in grid');
$x = $f;
$x['operation']['gridW'] = 6000.0;
$x['operation']['localFeedback']['b']['gridW'] = 6000.0;
$x['operation']['limits']['importW'] = 4000.0;
$x['operation']['batteries'][0]['maxDischargeW'] = 2000.0;
controlContext($x);
controlRefuses(static fn() => controlRun($x), 'unreachable hard grid limit refuses correction');
$x = $f;
$x['operation']['gridW'] = 3000.0;
$x['operation']['localFeedback']['b']['gridW'] = 3000.0;
$x['operation']['limits']['importW'] = 2000.0;
$x['consumers'][0]['Daten']['Leistungswerte_W'] = [0, ['Von_W' => -1000, 'Bis_W' => -500]];
controlContext($x);
controlRefuses(static fn() => controlRun($x), 'unordered invalid offer rejected by message contract');
$x['consumers'][0]['Daten']['Leistungswerte_W'] = [['Von_W' => -1000, 'Bis_W' => -500], 0];
controlRefuses(static fn() => controlRun($x), 'offered discharge insufficient for grid boundary refused');
$x = $f;
$x['operation']['batteries'][0]['gridCharging'] = false;
controlContext($x);
controlCheck(controlRun($x)['report']['targetW'] === 2000, 'PV-only charging respects residual after other consumers');
$source = file_get_contents(__DIR__ . '/../../libs/NetzfahrplanV4Regelung.php');
controlCheck(!preg_match('/\b(?:IPS_|SetValue|GetValue|RequestAction|curl_exec|shell_exec)/', $source), 'pure allocation helper has no I/O or commands');
echo "TOTAL $count control checks passed; synthetic plans and measurements only.\n";
+9 -4
View File
@@ -13,21 +13,23 @@ namespace Belevo\EnelixEMS {
class FeedbackReceiver {
use ManagerNetzfahrplanV4EmpfangTrait;
public int $InstanceID=17004;public array $props,$attrs,$op;
public function __construct($r,$op){$this->op=$op;$this->props=['NetzfahrplanV4EmpfangAktiv'=>true,'NetzfahrplanV4SchattenAktiv'=>true,'NetzfahrplanAktiv'=>false,'MesswertMaxAlter'=>60,'AnlagenBatterien'=>json_encode([['ID'=>'b','LeistungVariableID'=>1002,'SOCVariableID'=>1001,'Leistungsfaktor'=>1]])];$this->attrs=['LizenzInstallationID'=>$r['installationId'],'NetzfahrplanV4Empfang'=>json_encode(['response'=>$r,'receivedAt'=>time()]),'NetzfahrplanV4EmpfangStatus'=>'{}'];}
public function __construct($r,$op){$this->op=$op;$this->props=['PrognoseAktiv'=>true,'NetzfahrplanRegelungAktiv'=>false,'NetzfahrplanV4SchattenAktiv'=>true,'NetzfahrplanAktiv'=>false,'MesswertMaxAlter'=>60,'AnlagenBatterien'=>json_encode([['ID'=>'b','LeistungVariableID'=>1002,'SOCVariableID'=>1001,'Leistungsfaktor'=>1]])];$this->attrs=['LizenzInstallationID'=>$r['installationId'],'NetzfahrplanV4Empfang'=>json_encode(['response'=>$r,'receivedAt'=>time()]),'NetzfahrplanV4EmpfangStatus'=>'{}','PrognoseV4Serverstatus'=>'{}'];}
private function ReadPropertyBoolean($k){return $this->props[$k];}
private function ReadPropertyString($k){return $this->props[$k];}
private function ReadPropertyInteger($k){return $this->props[$k];}
private function ReadAttributeString($k){return $this->attrs[$k];}
private function aktiveVerbraucherIDs(){return [44234];}
private function prognoseLernenAktiv(){return $this->props['PrognoseAktiv'];}
private function berechtigungLizenziert($k){return true;}
public function GetNetzfahrplanV4Diagnose(){return json_encode(['operation'=>$this->op]);}
}
}
namespace {
function ENELIX_GetV4BatterieRueckmeldung($id){$GLOBALS['now']+=($GLOBALS['feedbackDelay']??0);$GLOBALS['feedback']['checkedAt']=$GLOBALS['now'];return json_encode($GLOBALS['feedback']);}
function ENELIX_GetV4BatterieRueckmeldungCache($id){$GLOBALS['cacheReads']++;$GLOBALS['now']+=($GLOBALS['feedbackDelay']??0);return json_encode($GLOBALS['feedback']);}
function ENELIX_GetV4BatterieRueckmeldung($id){throw new RuntimeException('Synchronous device read forbidden in preview');}
require_once __DIR__.'/../V4Receiver/fixture.php';
use Belevo\EnelixEMS\FeedbackReceiver;
[$response,$id,$op,$now]=receiverFixture();$configured=true;$count=0;$feedbackDelay=0;
[$response,$id,$op,$now]=receiverFixture();$configured=true;$count=0;$feedbackDelay=0;$cacheReads=0;
$feedback=['kind'=>'local_physical_feedback','version'=>1,'installationId'=>$id,'assetId'=>'b','managerId'=>17004,'batteryInstanceId'=>44234,'batteryW'=>0.,'gridW'=>$op['gridW'],'checkedAt'=>$now,'estimated'=>true,'usableForTrial'=>false,'canDispatch'=>false,'configHash'=>str_repeat('a',64)];
function ck($v,$t){global$count;if(!$v)throw new RuntimeException('FAIL '.$t);$count++;echo "PASS $t\n";}
$m=new FeedbackReceiver($response,$op);$p=json_decode($m->GetNetzfahrplanV4Vorschau(),true);
@@ -35,7 +37,10 @@ namespace {
ck($p['previewBatteryW']==3000,'net target translated once to total battery demand');
ck($p['canDispatch']===false&&$p['localFeedback']['b']['estimated']===true,'model feedback remains marked and preview grants no dispatch');
$feedbackDelay=1;$p=json_decode($m->GetNetzfahrplanV4Vorschau(),true);$feedbackDelay=0;
ck($p['status']==='preview_only','feedback timestamp produced after device read is compared with post-read clock');
ck($p['status']==='preview_only'&&$feedback['checkedAt']===$now-1,'cached feedback timestamp is retained across delayed preview read');
ck($cacheReads===2,'preview only reads confirmed feedback cache, never synchronous device getter');
$now+=2;$p=json_decode($m->GetNetzfahrplanV4Vorschau(),true);$now-=2;
ck($p['status']==='unavailable'&&$p['previewBatteryW']===null,'stale confirmed cache is rejected without a synchronous refresh or legacy fallback');
$feedback=['status'=>'unavailable','reason'=>'feedback_device_read_failed:grid','batteryW'=>null,'gridW'=>null];$p=json_decode($m->GetNetzfahrplanV4Vorschau(),true);
ck($p['status']==='unavailable'&&$p['previewBatteryW']===null,'configured feedback failure never falls back to held virtual display');
ck(str_contains($p['reason'],'feedback_unavailable:feedback_device_read_failed:grid'),'configured feedback exposes bounded root cause without credentials');
+327
View File
@@ -0,0 +1,327 @@
<?php
declare(strict_types=1);
namespace Belevo\EnelixEMS;
if (PHP_SAPI !== 'cli' || function_exists('IPS_GetKernelVersion')) {
throw new \RuntimeException('Standalone PHP CLI only; never load mocks into Symcon.');
}
require_once __DIR__ . '/../../libs/NetzfahrplanV4Einrichtung.php';
require_once __DIR__ . '/../../libs/NetzfahrplanV4RueckmeldungEinrichtung.php';
require_once __DIR__ . '/../../libs/BatterieNetzfahrplanV4RueckmeldungTrait.php';
$count = 0;
function feedbackSetupCheck(bool $ok, string $label): void
{
global $count;
if (!$ok) throw new \RuntimeException('FAIL ' . $label);
++$count;
echo 'PASS ' . $label . PHP_EOL;
}
function feedbackSetupRefuses(callable $call, string $label): void
{
try { $call(); }
catch (\Throwable $error) { feedbackSetupCheck(true, $label); return; }
throw new \RuntimeException('Expected refusal: ' . $label);
}
function feedbackSetupFixture(): array
{
$assets = [['ID' => 'battery', 'WechselrichterID' => 'battery-wr', 'Kopplung' => 'ac',
'LeistungVariableID' => 103, 'SOCVariableID' => 104, 'Leistungsfaktor' => 1.0]];
$objects = [
101 => ['parentID' => 201, 'ident' => 'Grid', 'type' => 2],
102 => ['parentID' => 202, 'ident' => 'PV', 'type' => 2],
103 => ['parentID' => 203, 'ident' => 'Battery', 'type' => 2],
];
$installationId = '11111111-2222-4333-8444-555555555555';
$setup = NetzfahrplanV4Einrichtung::erstellen($installationId, 900,
['NetzleistungVariableID' => 101, 'Netzleistungsfaktor' => 1.0, 'MesswertMaxAlter' => 60],
[['ID' => 'pv-wr', 'Typ' => 'pv', 'IstleistungVariableID' => 102, 'Istleistungsfaktor' => 1000],
['ID' => 'battery-wr', 'Typ' => 'battery']],
$assets, static fn(int $id): array => $objects[$id]);
return ['capture' => $setup['capture'], 'installationId' => $installationId,
'managerId' => 900, 'batteryId' => 901, 'assets' => $assets,
'controllers' => [['InstanzID' => 901, 'IstleistungVariableID' => 103,
'LadezustandVariableID' => 104, 'NetzleistungVariableID' => 101, 'MesswertMaxAlter' => 60]]];
}
function feedbackSetupCreate(array $f): array
{
return NetzfahrplanV4RueckmeldungEinrichtung::erstellen($f['capture'], $f['installationId'],
$f['managerId'], $f['batteryId'], $f['assets'], $f['controllers']);
}
$f = feedbackSetupFixture();
$config = feedbackSetupCreate($f);
feedbackSetupCheck($config === feedbackSetupCreate($f), 'automatic feedback deterministic');
feedbackSetupCheck($config['installationId'] === $f['installationId'] && $config['managerId'] === 900
&& $config['batteryInstanceId'] === 901 && $config['assetId'] === 'battery', 'single exact plant and controller binding');
feedbackSetupCheck($config['mode'] === 'physical_sum' && !$config['allowEstimatedForTrial'], 'only separately metered physical feedback');
feedbackSetupCheck(count($config['sources']) === 2 && array_column($config['sources'], 'variableId') === [103, 101],
'battery and grid sampled exactly once without PV double count');
feedbackSetupCheck(array_column($config['sources'], 'role') === ['physical', 'grid'], 'no invented request or gateway sources');
feedbackSetupCheck($config['maxAgeSeconds'] === 60 && $config['maxSkewSeconds'] === 30, 'existing strict freshness defaults');
feedbackSetupCheck($config['idleToleranceW'] === 0.0 && $config['trackingToleranceW'] === 0.0, 'no relaxed tracking tolerance');
feedbackSetupCheck(!str_contains(json_encode($config), 'watchdog') && !str_contains(json_encode($config), 'canDispatch')
&& !str_contains(json_encode($config), 'confirmedAt'), 'setup creates neither confirmation nor control permission');
$roundtrip = json_decode(json_encode($f), true);
feedbackSetupCheck(feedbackSetupCreate($roundtrip) === $config, 'JSON float normalization stable');
$changed = $f;
$changed['controllers'][0]['MesswertMaxAlter'] = 8;
feedbackSetupCheck(feedbackSetupCreate($changed)['maxAgeSeconds'] === 8
&& feedbackSetupCreate($changed)['maxSkewSeconds'] === 8, 'controller stricter freshness preserved');
$changed = $f;
$changed['controllers'][0]['MesswertMaxAlter'] = 600;
feedbackSetupCheck(feedbackSetupCreate($changed)['maxAgeSeconds'] === 60, 'large local setting cannot relax feedback freshness');
$changed = $f;
foreach ($changed['capture']['accounting']['sources'] as &$source) {
if ($source['role'] === 'physical_storage') $source['maxAgeSeconds'] = 7;
}
unset($source);
feedbackSetupCheck(feedbackSetupCreate($changed)['maxAgeSeconds'] === 7, 'capture source stricter freshness preserved');
$changed = $f;
$changed['assets'][0]['Name'] = 'private name';
$changed['controllers'][0]['Password'] = 'do-not-export';
feedbackSetupCheck(feedbackSetupCreate($changed) === $config, 'only allowlisted nonsecret fields leave setup');
$changed = $f;
$changed['installationId'] = $changed['capture']['installationId'] = '99999999-2222-4333-8444-555555555555';
feedbackSetupCheck(NetzfahrplanV4Rueckmeldung::fingerprint(feedbackSetupCreate($changed))
!== NetzfahrplanV4Rueckmeldung::fingerprint($config), 'distinct plant cannot share feedback identity');
$changed = $f;
foreach ($changed['capture']['accounting']['sources'] as &$source) {
if ($source['role'] === 'physical_storage') $source['parentId'] = 205;
}
unset($source);
feedbackSetupCheck(NetzfahrplanV4Rueckmeldung::fingerprint(feedbackSetupCreate($changed))
!== NetzfahrplanV4Rueckmeldung::fingerprint($config), 'changed physical identity changes fingerprint');
foreach ([
static function (array &$x): void { $x['managerId']++; },
static function (array &$x): void { $x['batteryId']++; },
static function (array &$x): void { $x['assets'] = []; },
static function (array &$x): void { $x['assets'][] = $x['assets'][0]; },
static function (array &$x): void { $x['controllers'] = []; },
static function (array &$x): void { $x['controllers'][] = $x['controllers'][0]; },
static function (array &$x): void { $x['controllers'][0]['Aktiv'] = false; },
static function (array &$x): void { $x['controllers'][0]['IstleistungVariableID'] = 105; },
static function (array &$x): void { $x['controllers'][0]['LadezustandVariableID'] = 105; },
static function (array &$x): void { $x['controllers'][0]['NetzleistungVariableID'] = 105; },
static function (array &$x): void { $x['controllers'][0]['MesswertMaxAlter'] = 0; },
static function (array &$x): void { unset($x['assets'][0]['SOCVariableID']); },
static function (array &$x): void { $x['assets'][0]['SOCVariableID'] = $x['controllers'][0]['LadezustandVariableID'] = 103; },
static function (array &$x): void { $x['assets'][0]['Virtuell'] = true; },
static function (array &$x): void { $x['assets'][0]['SDLAktiv'] = true; },
static function (array &$x): void { $x['assets'][0]['Kopplung'] = 'dc'; },
static function (array &$x): void { $x['assets'][0]['ID'] = 'different'; },
static function (array &$x): void { $x['assets'][0]['Leistungsfaktor'] = -1; },
static function (array &$x): void { unset($x['capture']['accounting']['storageMode']); },
] as $index => $change) {
$changed = $f;
$change($changed);
feedbackSetupRefuses(static fn() => feedbackSetupCreate($changed), 'ambiguous or incompatible binding ' . $index);
}
foreach (['grid', 'physical_storage'] as $role) {
foreach ([-1, 1000] as $factor) {
$changed = $f;
foreach ($changed['capture']['accounting']['sources'] as &$source) {
if ($source['role'] === $role) $source['factorToW'] = $factor;
}
unset($source);
feedbackSetupRefuses(static fn() => feedbackSetupCreate($changed), 'controller raw W units ' . $role . ' ' . $factor);
}
}
$changed = $f;
$changed['capture']['accounting']['sources'][] = [
'key' => 'other', 'role' => 'reference', 'variableId' => 105, 'parentId' => 205,
'ident' => 'Other', 'factorToW' => 1.0, 'maxAgeSeconds' => 60, 'dependsOn' => [],
];
feedbackSetupRefuses(static fn() => feedbackSetupCreate($changed), 'specialist accounting roles require explicit mapping');
$changed = $f;
$changed['capture']['accounting']['sources'][0]['dependsOn'] = ['grid'];
feedbackSetupRefuses(static fn() => feedbackSetupCreate($changed), 'derived physical source cannot become automatic feedback');
$changed = $f;
$changed['capture']['extraSources'][] = [
'key' => 'soc', 'variableId' => 104, 'parentId' => 203, 'ident' => 'SOC', 'unit' => 'percent',
];
feedbackSetupRefuses(static fn() => feedbackSetupCreate($changed), 'specialist extra sources require explicit mapping');
$GLOBALS['feedbackTestNow'] = 1791450000;
$GLOBALS['feedbackTestDeviceCalls'] = 0;
$GLOBALS['feedbackTestGetterCalls'] = 0;
$GLOBALS['feedbackTestManagerRunning'] = false;
$GLOBALS['feedbackTestGetterConfig'] = '';
$GLOBALS['feedbackTestReadSucceeds'] = true;
$GLOBALS['feedbackTestModules'] = [201 => NetzfahrplanV4Geraeteabruf::MBUS_DEVICE, 203 => NetzfahrplanV4Geraeteabruf::MODBUS_DEVICE];
$GLOBALS['feedbackTestObjects'] = [101 => ['ParentID' => 201, 'ObjectIdent' => 'Grid'], 103 => ['ParentID' => 203, 'ObjectIdent' => 'Battery']];
$GLOBALS['feedbackTestValues'] = [101 => 1000, 103 => 250];
function time(): int { return $GLOBALS['feedbackTestNow']; }
function usleep(int $duration): void {}
function ENELIX_GetV4RueckmeldungKonfiguration(int $manager, int $battery): string
{
++$GLOBALS['feedbackTestGetterCalls'];
if ($GLOBALS['feedbackTestManagerRunning']) throw new \RuntimeException('forbidden Manager -> battery cache -> Manager cycle');
if ($manager !== 900 || $battery !== 901) throw new \RuntimeException('wrong getter invocation');
return $GLOBALS['feedbackTestGetterConfig'];
}
function IPS_SemaphoreEnter(string $key, int $timeout): bool { return true; }
function IPS_SemaphoreLeave(string $key): void {}
function IPS_VariableExists(int $id): bool { return isset($GLOBALS['feedbackTestObjects'][$id]); }
function IPS_GetVariable(int $id): array
{
return ['VariableUpdated' => time() - 120, 'VariableChanged' => time() - 180];
}
function IPS_GetObject(int $id): array { return $GLOBALS['feedbackTestObjects'][$id]; }
function GetValue(int $id): int { return $GLOBALS['feedbackTestValues'][$id]; }
function IPS_InstanceExists(int $id): bool { return isset($GLOBALS['feedbackTestModules'][$id]); }
function IPS_GetInstance(int $id): array { return ['ModuleInfo' => ['ModuleID' => $GLOBALS['feedbackTestModules'][$id]]]; }
function MBUS_UpdateValues(int $id): bool
{
++$GLOBALS['feedbackTestDeviceCalls'];
return $GLOBALS['feedbackTestReadSucceeds'];
}
function ModBus_RequestRead(int $id): bool { return MBUS_UpdateValues($id); }
final class FeedbackSetupBattery
{
use BatterieNetzfahrplanV4RueckmeldungTrait;
public int $InstanceID = 901;
public array $managers = [], $buffers = [], $timers = [];
public string $property = '{}';
public function register(): void { $this->registriereV4Rueckmeldung(); }
public function configure(): void { $this->konfiguriereV4Rueckmeldung(); }
public function tick(): void { $this->aktualisiereV4Rueckmeldung(); }
public function effective(): string { return $this->effektiveV4RueckmeldungKonfiguration(); }
private function zugeordneteManagerIDs(): array
{
if ($GLOBALS['feedbackTestManagerRunning']) throw new \RuntimeException('cache must not inspect Manager assignment');
return $this->managers;
}
protected function ReadPropertyString(string $key): string { return $this->property; }
protected function RegisterPropertyString(string $key, string $value): void {}
protected function RegisterTimer(string $key, int $interval, string $script): void { $this->timers[$key] = compact('interval', 'script'); }
protected function SetTimerInterval(string $key, int $interval): void { $this->timers[$key]['interval'] = $interval; }
protected function SetBuffer(string $key, string $value): void { $this->buffers[$key] = $value; }
protected function GetBuffer(string $key): string { return $this->buffers[$key] ?? ''; }
}
$battery = new FeedbackSetupBattery();
$battery->register();
$timerScript = $battery->timers['NetzfahrplanV4RueckmeldungAktualisieren']['script'];
feedbackSetupCheck($timerScript === "ENELIX_BatterieTimerAktion(\$_IPS['TARGET'], 'NetzfahrplanV4RueckmeldungAktualisieren', false);",
'native timer wrapper unchanged');
$battery->configure();
$battery->tick();
feedbackSetupCheck($battery->timers['NetzfahrplanV4RueckmeldungAktualisieren']['interval'] === 1000,
'existing sampler discovers assignment after battery setup');
feedbackSetupCheck($GLOBALS['feedbackTestDeviceCalls'] === 0 && $GLOBALS['feedbackTestGetterCalls'] === 0,
'unassigned battery neither queries manager nor reads devices');
$battery->managers = [900];
$battery->tick();
feedbackSetupCheck($GLOBALS['feedbackTestDeviceCalls'] === 0, 'empty manager config when learning off performs no device I/O');
$GLOBALS['feedbackTestGetterConfig'] = json_encode($config, JSON_THROW_ON_ERROR | JSON_PRESERVE_ZERO_FRACTION);
feedbackSetupRefuses(static fn() => $battery->GetV4BatterieRueckmeldungCache(), 'assignment alone never produces fake fresh sample');
$battery->tick();
feedbackSetupCheck($GLOBALS['feedbackTestDeviceCalls'] === 2, 'late manager assignment uses existing sampler without ApplyChanges');
$sample = json_decode($battery->GetV4BatterieRueckmeldungCache(), true);
feedbackSetupCheck($sample['batteryW'] === 250 && $sample['gridW'] === 1000, 'confirmed physical snapshot values');
feedbackSetupCheck($sample['deviceReadConfirmed'] && $sample['separatelyMeasured'] && !$sample['estimated'], 'real adapter response required');
feedbackSetupCheck($sample['sourceVariableOldestAt'] === time() - 120 && $sample['sourceOldestAt'] === time(),
'original unchanged variable timestamps retained separately from device confirmation');
feedbackSetupCheck(!$sample['canDispatch'] && !$sample['gatewayWatchdogVerified'], 'physical sampler never invents permission or watchdog');
$buffers = $battery->buffers;
$calls = $GLOBALS['feedbackTestDeviceCalls'];
$getters = $GLOBALS['feedbackTestGetterCalls'];
$GLOBALS['feedbackTestManagerRunning'] = true;
$battery->GetV4BatterieRueckmeldungCache();
feedbackSetupCheck($battery->buffers === $buffers && $GLOBALS['feedbackTestDeviceCalls'] === $calls,
'cache getter remains device I/O free and never renews timestamps');
feedbackSetupCheck($GLOBALS['feedbackTestGetterCalls'] === $getters,
'Manager -> battery cache never calls back into Manager or assignment lookup');
$GLOBALS['feedbackTestManagerRunning'] = false;
$context = json_decode($buffers['V4ConfirmedFeedbackContext'], true);
feedbackSetupCheck($context['autoMode'] && $context['propertyHash'] === hash('sha256', '{}')
&& json_decode($context['configuration'], true) === $config, 'sampler stores exact automatic probe context');
foreach ([
['autoMode', false], ['propertyHash', str_repeat('0', 64)], ['configuration', '{}'],
] as [$key, $value]) {
$brokenContext = $context;
$brokenContext[$key] = $value;
$battery->buffers['V4ConfirmedFeedbackContext'] = json_encode($brokenContext);
feedbackSetupRefuses(static fn() => $battery->GetV4BatterieRueckmeldungCache(), 'cached context drift refused: ' . $key);
$battery->buffers = $buffers;
}
foreach (['installationId', 'assetId', 'managerId', 'batteryInstanceId', 'configHash'] as $key) {
$brokenSample = $sample;
$brokenSample[$key] = is_int($brokenSample[$key]) ? $brokenSample[$key] + 1 : 'wrong';
$battery->buffers['V4ConfirmedFeedback'] = json_encode($brokenSample);
feedbackSetupRefuses(static fn() => $battery->GetV4BatterieRueckmeldungCache(), 'cached probe binding drift refused: ' . $key);
$battery->buffers = $buffers;
}
$battery->property = json_encode($config);
feedbackSetupRefuses(static fn() => $battery->GetV4BatterieRueckmeldungCache(), 'automatic to explicit property change invalidates old sample');
$battery->property = '{}';
$GLOBALS['feedbackTestNow'] += 3;
feedbackSetupRefuses(static fn() => $battery->GetV4BatterieRueckmeldungCache(), 'automatic cache expires after two seconds without contacting Manager');
$GLOBALS['feedbackTestNow'] -= 3;
$battery->buffers['V4ConfirmedFeedbackMono'] = (string) (hrtime(true) / 1e9 - 3);
feedbackSetupRefuses(static fn() => $battery->GetV4BatterieRueckmeldungCache(), 'automatic monotonic cache TTL remains two seconds');
$battery->buffers = $buffers;
$changedConfig = $config;
$changedConfig['maxAgeSeconds'] = 10;
$GLOBALS['feedbackTestGetterConfig'] = json_encode($changedConfig);
$getters = $GLOBALS['feedbackTestGetterCalls'];
feedbackSetupCheck(json_decode($battery->GetV4BatterieRueckmeldungCache(), true)['configHash'] === $sample['configHash']
&& $GLOBALS['feedbackTestGetterCalls'] === $getters,
'still-fresh probe stays bound to sampled configuration; Manager drift is observed only by sampler');
$battery->tick();
feedbackSetupCheck(json_decode($battery->GetV4BatterieRueckmeldungCache(), true)['configHash']
=== NetzfahrplanV4Rueckmeldung::fingerprint($changedConfig), 'next confirmed sample adopts changed automatic config');
$GLOBALS['feedbackTestGetterConfig'] = '';
$calls = $GLOBALS['feedbackTestDeviceCalls'];
$battery->tick();
feedbackSetupCheck($GLOBALS['feedbackTestDeviceCalls'] === $calls && $battery->buffers['V4ConfirmedFeedback'] === '',
'learning off clears automatic cache without device I/O');
$GLOBALS['feedbackTestGetterConfig'] = json_encode($config);
$battery->managers = [900, 902];
$getters = $GLOBALS['feedbackTestGetterCalls'];
$battery->tick();
feedbackSetupCheck($GLOBALS['feedbackTestDeviceCalls'] === $calls && $GLOBALS['feedbackTestGetterCalls'] === $getters,
'ambiguous manager assignment does not choose or read a device');
$battery->managers = [900];
$battery->property = " \n" . json_encode($config, JSON_THROW_ON_ERROR | JSON_PRESERVE_ZERO_FRACTION) . " \n";
$explicit = $battery->property;
$getters = $GLOBALS['feedbackTestGetterCalls'];
feedbackSetupCheck($battery->effective() === $explicit, 'explicit specialist configuration retained byte for byte');
$battery->tick();
feedbackSetupCheck($battery->property === $explicit && $GLOBALS['feedbackTestGetterCalls'] === $getters,
'explicit mapping never calls manager or rewrites properties');
feedbackSetupCheck($battery->buffers['V4ConfirmedFeedbackConfig'] === hash('sha256', $explicit),
'existing explicit configuration cache hashing unchanged');
$GLOBALS['feedbackTestManagerRunning'] = true;
$battery->GetV4BatterieRueckmeldungCache();
$GLOBALS['feedbackTestManagerRunning'] = false;
feedbackSetupCheck($GLOBALS['feedbackTestGetterCalls'] === $getters, 'explicit cache also performs no Manager callbacks');
$battery->property .= ' ';
feedbackSetupRefuses(static fn() => $battery->GetV4BatterieRueckmeldungCache(), 'explicit property byte drift invalidates probe');
$battery->property = '{}';
$badBinding = $config;
$badBinding['managerId'] = 902;
$GLOBALS['feedbackTestGetterConfig'] = json_encode($badBinding);
$calls = $GLOBALS['feedbackTestDeviceCalls'];
feedbackSetupRefuses(static fn() => $battery->tick(), 'manager returned wrong binding refused');
feedbackSetupCheck($GLOBALS['feedbackTestDeviceCalls'] === $calls, 'wrong automatic binding refused before device I/O');
$GLOBALS['feedbackTestGetterConfig'] = json_encode($config);
$GLOBALS['feedbackTestReadSucceeds'] = false;
feedbackSetupRefuses(static fn() => $battery->tick(), 'failed physical device response is never confirmed');
feedbackSetupCheck($battery->buffers['V4ConfirmedFeedback'] === '', 'failed response clears old confirmed sample');
$GLOBALS['feedbackTestReadSucceeds'] = true;
$GLOBALS['feedbackTestModules'] = [201 => 'unsupported', 203 => 'unsupported'];
$calls = $GLOBALS['feedbackTestDeviceCalls'];
feedbackSetupRefuses(static fn() => $battery->tick(), 'virtual or unsupported source parent refused');
feedbackSetupCheck($GLOBALS['feedbackTestDeviceCalls'] === $calls, 'unsupported parents not refreshed');
feedbackSetupCheck($battery->timers['NetzfahrplanV4RueckmeldungAktualisieren']['script'] === $timerScript,
'configuration and sampling never replace native timer callback');
echo 'TOTAL ' . $count . ' feedback setup checks passed' . PHP_EOL;
+27 -18
View File
@@ -35,10 +35,11 @@ namespace Belevo\EnelixEMS {
public array $op=[];
public function __construct($id,$op) {
$this->op=$op;
$this->props=['NetzfahrplanV4EmpfangAktiv'=>false,'NetzfahrplanV4SchattenAktiv'=>true,
$this->props=['PrognoseAktiv'=>false,'NetzfahrplanRegelungAktiv'=>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->attrs=['LizenzInstallationID'=>$id,'PrognoseInstallationsToken'=>str_repeat('x',43),
'PrognoseV4Serverstatus'=>'{}'];
$this->registriereNetzfahrplanV4Empfang();
}
public function receive() {$this->empfangeNetzfahrplanV4();}
@@ -47,6 +48,7 @@ namespace Belevo\EnelixEMS {
public function configure() {$this->konfiguriereNetzfahrplanV4Vorschau();}
public function GetNetzfahrplanV4Diagnose() { return json_encode(['operation'=>$this->op]); }
private function aktiveVerbraucherIDs() {return [];}
private function prognoseLernenAktiv() {return $this->props['PrognoseAktiv'];}
private function v4ManagerTestSession() {return $this->session;}
private function berechtigungLizenziert($key) {return $this->licensed;}
private function ReadPropertyBoolean($key) {return (bool)$this->props[$key];}
@@ -55,7 +57,7 @@ namespace Belevo\EnelixEMS {
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');
if (!str_starts_with($key,'NetzfahrplanV4') && $key!=='PrognoseV4Serverstatus') throw new \RuntimeException('Unexpected external attribute');
$this->attrs[$key]=$val;
}
private function WriteAttributeInteger($key,$val) { $this->attrs[$key]=$val; }
@@ -86,8 +88,9 @@ namespace {
$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=fixture();$h->receive();check(count($requests)===0,'learning disabled makes no network request');
$h->props['NetzfahrplanV4EmpfangAktiv']=true;$h->receive();check(count($requests)===0,'retired receiver flag cannot enable learning or network');
$h->props['PrognoseAktiv']=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');
@@ -97,25 +100,25 @@ namespace {
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();
$h=fixture();$h->props['PrognoseAktiv']=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();
$h=fixture();$h->props['PrognoseAktiv']=true;$h->props['NetzfahrplanAktiv']=true;$h->receive();check(count($requests)===2 && $h->props['NetzfahrplanRegelungAktiv']===false,'obsolete legacy flag neither blocks preview nor enables V4 control');
$h=fixture();$h->props['PrognoseAktiv']=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);
$h=fixture();$h->props['PrognoseAktiv']=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();
$h=fixture();$h->props['PrognoseAktiv']=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');
$h=fixture();$h->props['PrognoseAktiv']=true;$h->receive();$h->stop();check($h->attrs['NetzfahrplanV4Empfang']==='{}' && $h->timers['NetzfahrplanV4VorschauAktualisieren']===0,'apply/restart invalidates cached plan');
$h=fixture();$h->props['PrognoseAktiv']=true;$h->licensed=false;$h->receive();check(count($requests)===0,'no license no read');
$h=fixture();$h->props['PrognoseAktiv']=true;$GLOBALS['lock']=true;$h->receive();check(count($requests)===0,'concurrent receiver does not send');
$h=fixture();$h->props['PrognoseAktiv']=true;$h->receive();$h->props['PrognoseAktiv']=false;$h->update();check(json_decode($h->vars['NetzfahrplanV4VorschauJSON'],true)['previewBatteryW']===null,'disable clears displayed target');
$source=file_get_contents(__DIR__.'/../../libs/ManagerNetzfahrplanV4EmpfangTrait.php');
check(!str_contains($source, 'netzfahrplanV4EmpfangFormular'), 'receiver no longer owns a separate form panel');
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');
$h=fixture();$h->props['NetzfahrplanV4EmpfangAktiv']=true;$h->configure();
$h=fixture();$h->props['PrognoseAktiv']=true;$h->configure();
check($h->timers['NetzfahrplanV4EmpfangAktualisieren']===10000,'refresh has an independent timer');
$h->session=['kind'=>'local_active_test_consent'];$h->receive();$saved=$h->attrs['NetzfahrplanV4Empfang'];
check($h->attrs['NetzfahrplanV4NaechsterAbruf']===$clock+45,'active refresh due after 45 seconds');
@@ -125,16 +128,22 @@ namespace {
advance($h,46);$h->receive();
check($h->attrs['NetzfahrplanV4Empfang']==='{}','active envelope older than 90 seconds is discarded');
$h->stop();check($h->timers['NetzfahrplanV4EmpfangAktualisieren']===0,'stop disables independent refresh timer');
$h=fixture();$h->props['NetzfahrplanV4EmpfangAktiv']=true;$h->session=['kind'=>'local_active_test_consent'];$h->receive();
$h=fixture();$h->props['PrognoseAktiv']=true;$h->session=['kind'=>'local_active_test_consent'];$h->receive();
$future=json_decode($h->attrs['NetzfahrplanV4Empfang'],true);$future['receivedAt']=$clock+120;
$h->attrs['NetzfahrplanV4Empfang']=json_encode($future);advance($h,45);$GLOBALS['getCode']=503;$h->receive();
check($h->attrs['NetzfahrplanV4Empfang']==='{}','future receipt cannot survive failed refresh');
$h=fixture();$h->props['NetzfahrplanV4EmpfangAktiv']=true;$GLOBALS['response']['pending']=['sequence'=>1];$GLOBALS['response']['fresh']=false;$h->receive();
$h=fixture();$h->props['PrognoseAktiv']=true;$GLOBALS['response']['pending']=['sequence'=>1];$GLOBALS['response']['fresh']=false;$h->receive();
check($h->attrs['NetzfahrplanV4NaechsterAbruf']===$clock+15 && $h->attrs['NetzfahrplanV4Empfang']==='{}',
'pending startup retries after 15 seconds without creating a usable envelope');
$v=json_decode($h->GetNetzfahrplanV4Vorschau(),true);
check(($v['reception']['status']??null)==='rejected_or_unavailable','unavailable preview preserves receiver rejection detail');
$h=fixture();$h->props['NetzfahrplanV4EmpfangAktiv']=true;$GLOBALS['getCode']=429;$h->receive();
$h=fixture();$h->props['PrognoseAktiv']=true;$GLOBALS['getCode']=429;$h->receive();
check($h->attrs['NetzfahrplanV4NaechsterAbruf']===$clock+60,'startup rate-limit response retains ordinary backoff');
$h=fixture();$h->props['PrognoseAktiv']=true;$h->props['NetzfahrplanRegelungAktiv']=true;$h->receive();
check($h->attrs['NetzfahrplanV4NaechsterAbruf']===$clock+45,'normal control switch uses active refresh without a test session');
$h=fixture();$h->props['PrognoseAktiv']=true;
$GLOBALS['response']['workflow']=['state'=>'ready','planningReady'=>true,'controlActive'=>false];$h->receive();
$workflow=json_decode($h->attrs['PrognoseV4Serverstatus'],true);
check($workflow['receivedAt']===$clock && $workflow['workflow']===$GLOBALS['response']['workflow'],'server workflow cached separately without inferring control activity');
echo "Total: $count checks. Mocked IPS and HTTP only.\n";
}
+200
View File
@@ -0,0 +1,200 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../../libs/NetzfahrplanV4Einrichtung.php';
use Belevo\EnelixEMS\NetzfahrplanV4Einrichtung as Setup;
use Belevo\EnelixEMS\NetzfahrplanV4Messaufnahme as Capture;
use Belevo\EnelixEMS\NetzfahrplanV4Bilanzierung as Accounting;
function setupFixture(): array
{
return [
'installationId' => '11111111-2222-4333-8444-555555555555',
'managerId' => 900,
'manager' => ['NetzleistungVariableID' => 101, 'Netzleistungsfaktor' => 1.0, 'MesswertMaxAlter' => 60],
'inverters' => [
['ID' => 'pv-wr', 'Typ' => 'pv', 'IstleistungVariableID' => 102, 'Istleistungsfaktor' => 1.0],
['ID' => 'battery-wr', 'Typ' => 'battery'],
],
'batteries' => [['ID' => 'battery', 'WechselrichterID' => 'battery-wr', 'Kopplung' => 'ac',
'LeistungVariableID' => 103, 'Leistungsfaktor' => 1.0]],
'objects' => [
101 => ['parentID' => 201, 'ident' => 'Grid', 'type' => 2],
102 => ['parentID' => 202, 'ident' => 'PV', 'type' => 2],
103 => ['parentID' => 203, 'ident' => 'Battery', 'type' => 2],
],
];
}
function setupCreate(array $f): array
{
return Setup::erstellen($f['installationId'], $f['managerId'], $f['manager'], $f['inverters'], $f['batteries'],
static fn(int $id): array => $f['objects'][$id] ?? []);
}
function setupRecord(array $setup, array $f, int $now, array $changes = []): array
{
$samples = [];
$values = [101 => 1000, 102 => 5000, 103 => 2000];
foreach ($f['objects'] as $id => $o) {
$samples[$id] = ['value' => $values[$id], 'updated' => $now, 'changed' => $now - 30,
'parentID' => $o['parentID'], 'ident' => $o['ident']];
$samples[$id] = array_replace($samples[$id], $changes[$id] ?? []);
}
return Capture::capture($setup['capture'], static fn(int $id): array => $samples[$id], static fn(): int => $now);
}
if (($argv[1] ?? '') === '--fixture') {
$f = setupFixture();
$setup = setupCreate($f);
echo json_encode(['setup' => $setup, 'captureJson' => json_encode($setup['capture'], JSON_THROW_ON_ERROR | JSON_PRESERVE_ZERO_FRACTION),
'record' => setupRecord($setup, $f, 1791450000)], JSON_THROW_ON_ERROR | JSON_PRESERVE_ZERO_FRACTION);
exit(0);
}
$count = 0;
function setupCheck(bool $ok, string $label): void
{
global $count;
if (!$ok) {
throw new RuntimeException('FAIL ' . $label);
}
++$count;
echo 'PASS ' . $label . "\n";
}
function setupRefuses(callable $call, string $label): void
{
try {
$call();
} catch (InvalidArgumentException $e) {
setupCheck(true, $label);
return;
}
throw new RuntimeException('Expected refusal: ' . $label);
}
$f = setupFixture();
$setup = setupCreate($f);
setupCheck($setup === setupCreate($f), 'idempotent deterministic setup');
setupCheck($setup['datasetId'] === $setup['dataset']['datasetId'], 'single dataset identity');
setupCheck($setup['mappingSha256'] === hash('sha256', json_encode($setup['capture'], JSON_THROW_ON_ERROR | JSON_PRESERVE_ZERO_FRACTION)), 'exact PHP capture hash');
setupCheck($setup['inventorySha256'] === $setup['capture']['reportedInventorySha256'], 'inventory identity bound to capture');
setupCheck($setup['dataset']['inventorySha256'] === $setup['inventorySha256'], 'inventory identity bound to dataset');
setupCheck($setup['dataset']['mappingSha256'] === $setup['mappingSha256'], 'mapping identity bound to dataset');
setupCheck($setup['dataset']['minimumTrainingHours'] === 24 && $setup['dataset']['minimumCoverage'] === 0.95, 'training requires sufficient measured basis');
setupCheck($setup['dataset']['historyDays'] === 30 && $setup['dataset']['maximumGapSeconds'] === 5, 'bounded history and gap defaults');
setupCheck($setup['dataset']['formula'] === 'physical_sum_v1' && $setup['dataset']['solarReference'] === null, 'ordinary physical formula');
setupCheck(count($setup['dataset']['sources']) === 3 && $setup['capture']['extraSources'] === [], 'one physical source per measured flow');
$roundtrip = json_decode(json_encode($f, JSON_THROW_ON_ERROR), true, 512, JSON_THROW_ON_ERROR);
setupCheck(setupCreate($roundtrip) === $setup, 'integer versus float JSON normalization stable');
$roundtripCapture = json_decode(json_encode($setup['capture'], JSON_THROW_ON_ERROR), true, 512, JSON_THROW_ON_ERROR);
setupCheck(Capture::configuration($roundtripCapture) === $setup['capture'], 'stored capture float representation normalized');
$changed = $f;
$changed['inverters'] = array_reverse($changed['inverters']);
setupCheck(setupCreate($changed) === $setup, 'topology row order irrelevant');
$changed = $f;
$changed['installationId'] = '99999999-2222-4333-8444-555555555555';
setupCheck(setupCreate($changed)['datasetId'] !== $setup['datasetId'], 'other installation never shares identity');
$changed = $f;
$changed['managerId']++;
setupCheck(setupCreate($changed)['datasetId'] !== $setup['datasetId'], 'manager identity bound');
$changed = $f;
$changed['manager']['Netzleistungsfaktor'] = 1000;
setupCheck(setupCreate($changed)['datasetId'] !== $setup['datasetId'], 'unit changes require new dataset');
$changed = $f;
$changed['objects'][103]['ident'] = 'OtherBattery';
setupCheck(setupCreate($changed)['datasetId'] !== $setup['datasetId'], 'changed source ident requires new dataset');
$changed = $f;
$changed['objects'][103]['parentID']++;
setupCheck(setupCreate($changed)['inventorySha256'] !== $setup['inventorySha256'], 'changed parent requires new inventory');
$changed = $f;
$changed['manager']['MesswertMaxAlter'] = 10;
setupCheck(array_unique(array_column(setupCreate($changed)['dataset']['sources'], 'maxAgeSeconds')) === [10], 'stricter age retained');
$changed['manager']['MesswertMaxAlter'] = 300;
setupCheck(array_unique(array_column(setupCreate($changed)['dataset']['sources'], 'maxAgeSeconds')) === [60], 'larger manager age does not relax default freshness');
$changed = $f;
$changed['manager']['Secret'] = 'do-not-export';
$changed['inverters'][0]['Unused'] = 'do-not-export';
$changed['batteries'][0]['Name'] = 'do-not-export';
$changed['objects'][101]['credentials'] = 'do-not-export';
setupCheck(setupCreate($changed) === $setup, 'unknown fields and credentials not copied or hashed');
setupCheck(!str_contains(json_encode($setup), 'controlEnabled') && !str_contains(json_encode($setup), 'confirmedAt'), 'configuration grants no dispatch or device confirmation');
$now = 1791450000;
$record = setupRecord($setup, $f, $now);
setupCheck($record['mappingSha256'] === $setup['mappingSha256'], 'real capture hashes exactly match dataset');
setupCheck($record['assessment']['baseLoadCandidateW'] === 4000.0, 'physical battery removed exactly once');
setupCheck($record['assessment']['reconstructedGridW'] === 1000.0 && $record['assessment']['arithmeticBalanceErrorW'] === 0.0, 'physical-only balance reconstructs grid');
setupCheck($record['assessment']['controlledVirtualW'] === 0.0 && $record['assessment']['allocationResidualW'] === 0.0
&& $record['assessment']['externalEffectW'] === 0.0, 'no invented virtual battery or SDL residual');
setupCheck($record['assessment']['status'] === 'consistent_candidate', 'physical-only observation does not require a virtual source');
setupCheck(!$record['trainingEligible'] && !$record['controlEligible'] && !$record['measurementBoundaryVerified'], 'capture grants no readiness or verified boundary');
$old = setupRecord($setup, $f, $now, [103 => ['updated' => $now - 61]]);
setupCheck($old['assessment']['status'] === 'quality_hold', 'stale physical source remains blocked');
$key = array_column($setup['capture']['accounting']['sources'], 'key', 'variableId')[103];
setupCheck($old['raw'][$key]['sourceUpdatedAt'] === $now - 61, 'original stale source timestamp retained');
$wrong = setupRecord($setup, $f, $now, [103 => ['ident' => 'reused']]);
setupCheck($wrong['assessment'] === null && $wrong['raw'][$key]['value'] === null, 'changed live identity cannot reuse old mapping');
$future = setupRecord($setup, $f, $now, [103 => ['updated' => $now + 1]]);
setupCheck($future['assessment'] === null, 'future source not freshened');
$discharge = setupRecord($setup, $f, $now, [101 => ['value' => -3000], 103 => ['value' => -2000]]);
setupCheck($discharge['assessment']['baseLoadCandidateW'] === 4000.0, 'discharge and export signs preserved');
foreach ([
['installationId', '../not-a-plant'], ['managerId', 0],
] as [$key, $bad]) {
$changed = $f; $changed[$key] = $bad;
setupRefuses(static fn() => setupCreate($changed), 'invalid ' . $key);
}
foreach ([0, '60', true] as $bad) {
$changed = $f; $changed['manager']['MesswertMaxAlter'] = $bad;
setupRefuses(static fn() => setupCreate($changed), 'invalid age ' . gettype($bad));
}
foreach ([0, true, '1', INF, NAN] as $bad) {
$changed = $f; $changed['manager']['Netzleistungsfaktor'] = $bad;
setupRefuses(static fn() => setupCreate($changed), 'invalid factor ' . gettype($bad));
}
foreach ([0, 3, '2', null] as $bad) {
$changed = $f; $changed['objects'][103]['type'] = $bad;
setupRefuses(static fn() => setupCreate($changed), 'non-numeric source type ' . gettype($bad));
}
foreach (['virtual', 'sharedStorage'] as $field) {
$changed = $f; $changed['objects'][103][$field] = true;
setupRefuses(static fn() => setupCreate($changed), 'explicit ' . $field . ' source rejected');
}
foreach (['SDLAktiv' => true, 'SDLLeistungVariableID' => 105] as $field => $value) {
$changed = $f; $changed['manager'][$field] = $value;
setupRefuses(static fn() => setupCreate($changed), 'SDL requires existing explicit mapping ' . $field);
}
$changed = $f; unset($changed['objects'][103]);
setupRefuses(static fn() => setupCreate($changed), 'missing object rejected');
$changed = $f; $changed['inverters'][0]['Typ'] = 'hybrid';
setupRefuses(static fn() => setupCreate($changed), 'ambiguous hybrid PV output rejected');
$changed = $f; $changed['batteries'][0]['Kopplung'] = 'dc';
setupRefuses(static fn() => setupCreate($changed), 'ambiguous DC power boundary rejected');
$changed = $f; $changed['batteries'][0]['LeistungVariableID'] = 101;
setupRefuses(static fn() => setupCreate($changed), 'grid source cannot also be physical battery');
$changed = $f; $changed['batteries'][] = $changed['batteries'][0]; $changed['batteries'][1]['ID'] = 'second';
setupRefuses(static fn() => setupCreate($changed), 'shared inverter cannot double count physical storage');
$changed = $f; $changed['batteries'][0]['WechselrichterID'] = 'absent';
setupRefuses(static fn() => setupCreate($changed), 'missing battery inverter rejected');
$changed = $f; $changed['inverters'][] = ['ID' => 'unassigned', 'Typ' => 'battery'];
setupRefuses(static fn() => setupCreate($changed), 'unassigned physical battery inverter rejected');
$changed = $f; $changed['inverters'][] = $changed['inverters'][0];
setupRefuses(static fn() => setupCreate($changed), 'duplicate inverter identifier rejected');
$changed = $f; $changed['inverters'] = [$changed['inverters'][1]];
setupRefuses(static fn() => setupCreate($changed), 'missing PV source is not invented as zero');
$c = $setup['capture']['accounting']; unset($c['storageMode']);
setupRefuses(static fn() => Accounting::konfigurieren($c), 'legacy mode retains explicit virtual source requirement');
$c = $setup['capture']['accounting']; $c['sources'][0]['role'] = 'controlled_virtual';
setupRefuses(static fn() => Accounting::konfigurieren($c), 'physical-only mode cannot hide virtual allocation');
try {
Setup::erstellen($f['installationId'], $f['managerId'], $f['manager'], $f['inverters'], $f['batteries'],
static function (int $id): array { throw new RuntimeException('do-not-export'); });
throw new RuntimeException('Expected reader failure');
} catch (InvalidArgumentException $e) {
setupCheck(!str_contains($e->getMessage(), 'do-not-export'), 'reader errors do not expose secrets');
}
echo "TOTAL $count setup checks passed; synthetic metadata and measurements only.\n";
+88
View File
@@ -0,0 +1,88 @@
"""Check literal Symcon registrations and reviewed dynamic families against READMEs.
This is a documentation-presence check, not a PHP evaluator or hardware test.
Only repository sources are read; no credentials, runtime state or device APIs.
"""
import pathlib
import re
import sys
ROOT = pathlib.Path(__file__).resolve().parents[1]
MODULES = {
'Manager': 'Manager', 'Batterie': 'Batterie',
'Warmwassererwaermer': 'Wassererwaermer', 'Pufferspeicher': 'Pufferspeicher',
'VerbraucherEinStufig': 'Verbraucher-1-Stufig', 'Waermepumpe': 'Waermepumpe',
'LadestationStandAlone': 'Ladestation-Stand-Alone',
'LadestationGateway': 'Ladestation-Gateway', 'EaseeGateway': 'Easee-Gateway',
'Energiediagramm': 'Energiediagramm', 'CC100Hardware': 'CC100-Hardware',
'ShellyModul': 'Shelly-Modul', 'Verbrauchskostenreport': 'Verbrauchskostenreport',
'VirtuelleBatterie': 'Virtuelle-Batterie', 'VGTSchnittstelle': 'VGT-Schnittstelle',
}
DYNAMIC = {
'Energiediagramm': ['FarbeEigenverbrauchRGB', 'FarbeAutarkieRGB',
'FarbeHintergrundRGB', 'FarbeHintergrundAkzent1RGB', 'FarbeHintergrundAkzent2RGB'],
'Manager': ['PVLeistungArchiv', 'HausverbrauchLeistungArchiv', 'NetzleistungArchiv',
'BatterieleistungArchiv', 'SDLLeistungArchiv', 'PVEnergie', 'Hausenergie',
'NetzbezugEnergie', 'EinspeisungEnergie', 'BatterieLadenEnergie',
'BatterieEntladenEnergie', 'SDLLadenEnergie', 'SDLEntladenEnergie',
'EnergieflussPV', 'EnergieflussNetz', 'EnergieflussHaus', 'EnergieflussBatterie',
'EnergieflussSDL', 'EnergieflussIst_', 'EnergieflussSoll_', 'DiagrammHausLeistung',
'DiagrammHausEnergie', 'DiagrammPVLeistung_', 'DiagrammPVEnergie_',
'DiagrammBatterieLeistung_', 'DiagrammBatterieLaden_', 'DiagrammBatterieEntladen_',
'DiagrammVerbraucherLeistung_', 'DiagrammVerbraucherEnergie_'],
'CC100Hardware': ['DI1', 'DI8', 'DO1', 'DO4', 'AI1', 'AI2', 'AO1', 'AO2', 'PT1', 'PT2'],
'ShellyModul': ['Typabbildung', 'Hash', 'switch:0.aenergy.total', 'Online', 'Switch.Set'],
}
def sources(path, seen=None):
seen = set() if seen is None else seen
path = path.resolve()
if path in seen:
return
seen.add(path)
text = path.read_text(encoding='utf-8')
yield text
for match in re.finditer(r"require_once\s+__DIR__\s*\.\s*['\"]([^'\"]+)['\"]", text):
target = (path.parent / match[1].lstrip('/')).resolve()
if ROOT in target.parents and target.is_file() and 'vendor' not in target.parts:
yield from sources(target, seen)
def main():
modules = sorted(ROOT.glob('*/module.php'))
errors, checked, dynamic = [], 0, 0
for source in modules:
name = source.parent.name
if name not in MODULES:
errors.append(name + ': missing documentation mapping')
continue
document = ROOT / 'docs/module' / MODULES[name] / 'README.md'
if not document.is_file() or not (source.parent / 'README.md').is_file():
errors.append(name + ': README missing')
continue
text = document.read_text(encoding='utf-8')
shared = ROOT / 'docs/Schnittstelle.md'
if name not in ['Manager', 'EaseeGateway'] and shared.exists():
text += shared.read_text(encoding='utf-8')
fields = set()
for code in sources(source):
fields.update(re.findall(r"->Register(Property|Variable)(?:Boolean|Integer|Float|String)\s*\(\s*['\"]([^'\"]+)['\"]", code))
for kind, ident in sorted(fields):
checked += 1
if ident not in text:
errors.append(name + ': undocumented ' + kind + ' ' + ident)
for ident in DYNAMIC.get(name, []):
dynamic += 1
if ident not in text:
errors.append(name + ': undocumented dynamic family ' + ident)
if not modules:
errors.append('No installable module sources found')
for error in errors:
print(error)
print(f'{len(modules)} modules; {checked} literal registrations; {dynamic} dynamic markers; {len(errors)} gaps')
return bool(errors)
if __name__ == '__main__':
sys.exit(main())