feat(manager): integrate forecast controls and merge V4 development
Tests / test (push) Failing after 49s

Merge the V4 development history with current manager, SDL and setpoint fixes. Retain guarded trial behavior and consolidate controls in Prognose / Forecast. Exclude the unverified accounting-evidence change; no runtime deployment or new dispatch permission.

Validated: 357 PHPUnit tests / 1758 assertions, 150 PHP syntax checks, 310 backend tests, 16 portal tests and isolated UI/receiver checks. develop and beta publication explicitly approved by Daniel Haefliger.
This commit is contained in:
dh
2026-10-06 14:22:16 +00:00
207 changed files with 20340 additions and 11 deletions
+31
View File
@@ -10,6 +10,24 @@ final class BatterieModulstrukturTest extends TestCase
{
private const MODULPFAD = __DIR__ . '/../Batterie/';
public function testV4StoppUndSollwertVerwerfenBleibenGemeinsamErhalten(): void
{
$modul = (string) file_get_contents(self::MODULPFAD . 'module.php');
self::assertMatchesRegularExpression(
"/case 'SichererZustand':\\s*\\\$this->V4BatterieTestStoppen\\(\\);"
. "\\s*\\\$this->verwerfeSollwert\\(\\);\\s*\\\$this->schreibeRegister\\(0, true\\);/",
$modul
);
self::assertSame(1, preg_match(
'/private function ManagerdatenEmpfangenIntern\\(array \\$daten\\): void(.*?)private function aktualisiere\\(/s',
$modul,
$empfang
));
self::assertStringContainsString('$this->verwerfeSollwert();', $empfang[1]);
self::assertStringContainsString('$this->aktualisiereIntern(false);', $empfang[1]);
self::assertStringNotContainsString('$this->aktualisiere(', $empfang[1]);
}
public function testModulIstVollstaendigUndMetadatenSindGueltig(): void
{
foreach (['module.json', 'form.json', 'module.php', 'README.md'] as $datei) {
@@ -127,4 +145,17 @@ final class BatterieModulstrukturTest extends TestCase
substr_count($manager, '{437FB683-517F-4FEC-8CCB-FE6B0A62B69E}')
);
}
public function testBatterieformularHatKeinenSeparatenV4Bereich(): void
{
$modul = (string) file_get_contents(self::MODULPFAD . 'module.php');
$formular = (string) file_get_contents(self::MODULPFAD . 'form.json');
self::assertStringNotContainsString('V4 physische Rueckmeldung', $modul);
self::assertStringNotContainsString('NetzfahrplanV4RueckmeldungKonfiguration', $formular);
self::assertStringNotContainsString("'name'=>'NetzfahrplanV4RueckmeldungKonfiguration'", $modul);
$rueckmeldung = (string) file_get_contents(__DIR__ . '/../libs/BatterieNetzfahrplanV4RueckmeldungTrait.php');
self::assertStringContainsString("RegisterPropertyString('NetzfahrplanV4RueckmeldungKonfiguration'", $rueckmeldung);
self::assertStringContainsString('$this->konfiguriereV4Rueckmeldung();', $modul);
}
}
+8 -3
View File
@@ -154,9 +154,14 @@ final class ManagerModulstrukturTest extends TestCase
"WriteAttributeString('LizenzInstallationID'",
$treffer[0]
);
self::assertStringContainsString(
"parent::ApplyChanges();\n\n \$this->initialisiereLizenzInstallationID();",
$inhalt
self::assertSame(1, preg_match(
'/public function ApplyChanges\(\): void(.*?)public function /s',
$inhalt,
$applyChanges
));
self::assertMatchesRegularExpression(
'/parent::ApplyChanges\(\);.*?\$this->initialisiereLizenzInstallationID\(\);/s',
$applyChanges[1]
);
self::assertSame(1, substr_count(
$inhalt,
+29
View File
@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace Belevo\EnelixEMS\Tests;
use PHPUnit\Framework\TestCase;
require_once __DIR__ . '/../libs/NetzfahrplanV4Betriebsdaten.php';
require_once __DIR__ . '/fixtures/NativeV4Scenarios.php';
final class NetzfahrplanV4BetriebsdatenTest extends TestCase
{
public function testOfflineInputScenarios(): void
{
self::assertCount(24, NativeV4Scenarios::run());
}
public function testSenderCannotChangeLivePlanOrActuators(): void
{
$source = file_get_contents(__DIR__ . '/../libs/ManagerNetzfahrplanV4Trait.php');
foreach (['sendeManagerdaten(', 'aktualisiereNetzfahrplan(', '->regeln(', '->SetStatus(', "WriteAttributeString('Netzfahrplan',"] as $forbidden) {
self::assertStringNotContainsString($forbidden, $source);
}
self::assertStringContainsString("RegisterPropertyBoolean('NetzfahrplanV4SchattenAktiv', false)", $source);
self::assertStringContainsString('/prognosis/planner-v4/operation', $source);
self::assertStringNotContainsString('/prognosis/schedule', $source);
}
}
+29
View File
@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace Belevo\EnelixEMS\Tests;
use PHPUnit\Framework\TestCase;
require_once __DIR__ . '/../libs/NetzfahrplanV4Bezugszaehler.php';
require_once __DIR__ . '/fixtures/V4BezugszaehlerScenarios.php';
final class NetzfahrplanV4BezugszaehlerTest extends TestCase
{
public function testReadOnlyCounterSources(): void
{
self::assertCount(20, V4BezugszaehlerScenarios::run());
}
public function testNoLegacyMeterOrActuatorFallback(): void
{
$source = file_get_contents(__DIR__ . '/../libs/NetzfahrplanV4Bezugszaehler.php');
foreach (['SetValue(', 'IPS_SetProperty(', 'RequestAction(', 'AC_Set', '53476'] as $forbidden) {
self::assertStringNotContainsString($forbidden, $source);
}
$trait = file_get_contents(__DIR__ . '/../libs/ManagerNetzfahrplanV4Trait.php');
self::assertStringNotContainsString("'NetzbezugEnergieVariableID'", $trait);
self::assertStringContainsString("RegisterPropertyString('NetzfahrplanV4BezugszaehlerQuellen', '[]')", $trait);
}
}
+16
View File
@@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace Belevo\EnelixEMS\Tests;
use PHPUnit\Framework\TestCase;
final class NetzfahrplanV4BilanzierungTest extends TestCase
{
public function testIsolatedAccountingChecks(): void
{
$pipes=[];
$p=proc_open([PHP_BINARY,__DIR__.'/V4Accounting/checks.php'],[0=>['pipe','r'],1=>['pipe','w'],2=>['pipe','w']],$pipes);
self::assertIsResource($p);fclose($pipes[0]);$out=stream_get_contents($pipes[1]);$err=stream_get_contents($pipes[2]);
fclose($pipes[1]);fclose($pipes[2]);self::assertSame(0,proc_close($p),$out.$err);self::assertSame('',$err);
self::assertStringContainsString('TOTAL 42 checks passed',$out);
}
}
+21
View File
@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace Belevo\EnelixEMS\Tests;
use PHPUnit\Framework\TestCase;
final class NetzfahrplanV4DatenTest extends TestCase
{
public function testApplicationDataPathWithIsolatedRuntime(): void
{
$pipes=[];
$process=proc_open([PHP_BINARY,__DIR__.'/V4ApplicationData/checks.php'],
[0=>['pipe','r'],1=>['pipe','w'],2=>['pipe','w']],$pipes);
self::assertIsResource($process);fclose($pipes[0]);
$out=stream_get_contents($pipes[1]);$err=stream_get_contents($pipes[2]);
fclose($pipes[1]);fclose($pipes[2]);
self::assertSame(0,proc_close($process),$out.$err);
self::assertStringContainsString('TOTAL 27 application-data checks passed',$out);
}
}
+29
View File
@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace Belevo\EnelixEMS\Tests;
use PHPUnit\Framework\TestCase;
final class NetzfahrplanV4EmpfangTest extends TestCase
{
/** @dataProvider scripts */
public function testIsolatedReceiverChecks(string $script): void
{
$pipes = [];
$process = proc_open([PHP_BINARY, __DIR__ . '/V4Receiver/' . $script],
[0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes);
self::assertIsResource($process);
fclose($pipes[0]);
$out = stream_get_contents($pipes[1]);$err = stream_get_contents($pipes[2]);
fclose($pipes[1]);fclose($pipes[2]);
self::assertSame(0, proc_close($process), $out . $err);
self::assertStringContainsString('Total:', $out);
}
public function scripts(): array
{
return [['protocol_checks.php'], ['receiver_checks.php']];
}
}
+16
View File
@@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace Belevo\EnelixEMS\Tests;
use PHPUnit\Framework\TestCase;
final class NetzfahrplanV4MessaufnahmeTest extends TestCase
{
/** @dataProvider scripts */
public function testPassiveCapture(string $name): void
{
$pipes=[];$p=proc_open([PHP_BINARY,__DIR__.'/V4DataCapture/'.$name],[0=>['pipe','r'],1=>['pipe','w'],2=>['pipe','w']],$pipes);
self::assertIsResource($p);fclose($pipes[0]);$out=stream_get_contents($pipes[1]);$err=stream_get_contents($pipes[2]);fclose($pipes[1]);fclose($pipes[2]);
self::assertSame(0,proc_close($p),$out.$err);self::assertStringContainsString('TOTAL',$out);
}
public function scripts(): array { return [['checks.php'],['native_checks.php']]; }
}
+16
View File
@@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace Belevo\EnelixEMS\Tests;
use PHPUnit\Framework\TestCase;
final class NetzfahrplanV4MesssichtTest extends TestCase
{
/** @dataProvider scripts */
public function testIsolatedViews(string $script): void
{
$p=proc_open([PHP_BINARY,__DIR__.'/V4SeparatedObservation/'.$script],[0=>['pipe','r'],1=>['pipe','w'],2=>['pipe','w']],$pipes);
self::assertIsResource($p);fclose($pipes[0]);$out=stream_get_contents($pipes[1]);$err=stream_get_contents($pipes[2]);fclose($pipes[1]);fclose($pipes[2]);
self::assertSame(0,proc_close($p),$out.$err);self::assertStringContainsString('TOTAL ',$out);self::assertSame('',$err);
}
public function scripts(): array {return [['checks.php'],['native_checks.php']];}
}
+24
View File
@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace Belevo\EnelixEMS\Tests;
use PHPUnit\Framework\TestCase;
final class NetzfahrplanV4RegeltestTest extends TestCase
{
public function testIsolatedControlledTrialStateMachines(): void
{
$pipes = [];
$process = proc_open([PHP_BINARY, __DIR__ . '/V4ControlTrial/checks.php'],
[0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes);
self::assertIsResource($process);
fclose($pipes[0]);
$out = stream_get_contents($pipes[1]);$err = stream_get_contents($pipes[2]);
fclose($pipes[1]);fclose($pipes[2]);
self::assertSame(0, proc_close($process), $out . $err);
self::assertSame('', $err);
self::assertStringContainsString('TOTAL 96 checks passed.', $out);
}
}
+26
View File
@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace Belevo\EnelixEMS\Tests;
use PHPUnit\Framework\TestCase;
final class NetzfahrplanV4RueckmeldungTest extends TestCase
{
private function checkScript(string $name): void
{
$pipes=[];
$p=proc_open([PHP_BINARY,__DIR__.'/V4Feedback/'.$name],
[0=>['pipe','r'],1=>['pipe','w'],2=>['pipe','w']],$pipes);
self::assertIsResource($p);fclose($pipes[0]);
$out=stream_get_contents($pipes[1]);$err=stream_get_contents($pipes[2]);
fclose($pipes[1]);fclose($pipes[2]);
self::assertSame(0,proc_close($p),$out.$err);
self::assertSame('',$err);self::assertStringContainsString('TOTAL ',$out);
}
public function testPhysicalFeedback(): void {$this->checkScript('checks.php');}
public function testConfirmedDeviceReads(): void {$this->checkScript('device_read_checks.php');}
public function testActualReceiverIntegration(): void {$this->checkScript('receiver_checks.php');}
public function testActualFallbackWrapper(): void {$this->checkScript('fallback_checks.php');}
public function testMessageWithoutVisibleDiagnostics(): void {$this->checkScript('message_checks.php');}
}
+116
View File
@@ -0,0 +1,116 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../../libs/ManagerPrognoseFormular.php';
use Belevo\EnelixEMS\ManagerPrognoseFormular;
$count = 0;
function check(bool $ok, string $name): void
{
global $count;
if (!$ok) {
throw new RuntimeException('FAIL ' . $name);
}
$count++;
echo 'PASS ' . $name . PHP_EOL;
}
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;
}
}
throw new RuntimeException('Forecast panel missing');
}
$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,
];
$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);
}
foreach ($base['elements'] as $index => $element) {
if (($element['name'] ?? '') !== 'PrognoseForecast') {
check($form['elements'][$index] === $element, 'unrelated panel unchanged: ' . $element['caption']);
}
}
foreach (['PrognoseAktiv', 'PrognoseSendeintervall', 'PrognoseLizenzInformation'] as $name) {
check($f[$name] === fields($base)[$name], 'existing forecast field unchanged: ' . $name);
}
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));
}
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');
$missing = $base;
$missing['elements'] = array_values(array_filter($missing['elements'], fn($e) => ($e['name'] ?? '') !== 'PrognoseForecast'));
try {
ManagerPrognoseFormular::integrieren($missing, $state);
check(false, 'missing forecast anchor rejected');
} catch (LogicException $e) {
check(true, 'missing forecast anchor rejected');
}
$duplicate = $base;
foreach ($base['elements'] as $element) {
if (($element['name'] ?? '') === 'PrognoseForecast') {
$duplicate['elements'][] = $element;
}
}
try {
ManagerPrognoseFormular::integrieren($duplicate, $state);
check(false, 'duplicate forecast anchor rejected');
} 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');
echo "Total: $count checks. Form generation only; no IPS or device calls.\n";
+131
View File
@@ -0,0 +1,131 @@
<?php
declare(strict_types=1);
if (function_exists('IPS_GetKernelVersion')) {
throw new RuntimeException('Isolated CLI test only; never run in the Symcon kernel.');
}
class IPSModule
{
public int $InstanceID = 17004;
public array $props = [], $attrs = [], $values = [], $fields = [];
protected function ReadPropertyBoolean(string $key): bool { return (bool) ($this->props[$key] ?? false); }
protected function ReadPropertyInteger(string $key): int { return (int) ($this->props[$key] ?? 0); }
protected function ReadPropertyString(string $key): string { return (string) ($this->props[$key] ?? ''); }
protected function ReadAttributeString(string $key): string { return (string) ($this->attrs[$key] ?? ''); }
protected function ReadAttributeInteger(string $key): int { return (int) ($this->attrs[$key] ?? 0); }
protected function GetValue(string $key) { return $this->values[$key] ?? false; }
protected function UpdateFormField(string $name, string $field, $value): void { $this->fields[$name][$field] = $value; }
}
function IPS_GetInstance(int $id): array { return ['InstanceStatus' => $GLOBALS['instanceStatus']]; }
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;
}
}
$count = 0;
function check(bool $ok, string $name): void
{
global $count;
if (!$ok) { throw new RuntimeException('FAIL ' . $name); }
$count++;
echo 'PASS ' . $name . PHP_EOL;
}
function fixture(): ManagerFormHarness
{
$h = new ManagerFormHarness();
$h->props = array_fill_keys(['PrognoseAktiv', 'NetzfahrplanV4SchattenAktiv',
'NetzfahrplanV4EmpfangAktiv', 'NetzfahrplanV4MessdatenAktiv', 'NetzfahrplanV4RegeltestErlaubt',
'NetzfahrplanV4AktivtestErlaubt', 'NetzfahrplanV4WatchdogVerzichtErlaubt'], true);
$h->props['NetzfahrplanAktiv'] = 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']);
$h->attrs['LizenzInstallationID'] = '10000000-0000-4000-8000-000000000001';
$h->attrs['LizenzLease'] = json_encode([
'status' => 'active', 'development' => true, 'installationId' => $h->attrs['LizenzInstallationID'],
'issuedAt' => gmdate('c', time() - 100), 'refreshAfter' => gmdate('c', time() + 100),
'offlineUntil' => gmdate('c', time() + 3600),
'entitlements' => [
['catalog_key' => 'manager_peak', 'quantity' => 1, 'status' => 'active'],
['catalog_key' => 'grid_schedule', 'quantity' => 1, 'status' => 'active'],
],
], JSON_THROW_ON_ERROR);
$h->attrs['NetzfahrplanV4AktivtestEnde'] = time() + 600;
$h->values = ['Aktiv' => true, 'NetzfahrplanV4Aktivtest' => false];
$GLOBALS['instanceStatus'] = 102;
return $h;
}
function rejected(ManagerFormHarness $h, $value, string $name): void
{
try {
$h->RequestAction('FormNetzfahrplanSchalten', $value);
} catch (RuntimeException | InvalidArgumentException $e) {
check($h->calls === [], $name);
return;
}
check(false, $name);
}
$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'] = '';
$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);
}
$h = fixture(); $h->props['Lizenzcode'] = '';
rejected($h, true, 'missing license cannot be bypassed by calling the action directly');
$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;
$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";
+66
View File
@@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
require_once __DIR__.'/../../libs/NetzfahrplanV4Bilanzierung.php';
use Belevo\EnelixEMS\NetzfahrplanV4Bilanzierung as B;
$count=0;
function check(bool $yes,string $name): void {global $count;if(!$yes)throw new RuntimeException('FAIL '.$name);$count++;echo 'PASS '.$name."\n";}
function fails(callable $f,string $name):void {try{$f();}catch(InvalidArgumentException $e){check(true,$name);return;}throw new RuntimeException('FAIL expected refusal: '.$name);}
function fixture():array {
$now=1790950000;$s=[];$m=[];
foreach(['grid'=>1000.,'pv'=>5000.,'physical_storage'=>3000.,'controlled_virtual'=>2000.,'external_virtual'=>1000.] as $role=>$value){
$id=101+count($s);$s[]=['key'=>$role,'role'=>$role,'variableId'=>$id,'parentId'=>10,'ident'=>$role,'factorToW'=>1.,'maxAgeSeconds'=>60,'dependsOn'=>[]];
$m[$id]=['value'=>$value,'updated'=>$now,'parentID'=>10,'ident'=>$role];
}
$s[3]['dependsOn']=['physical_storage'];$s[4]['dependsOn']=['physical_storage'];
return [['version'=>1,'sources'=>$s,'maxSkewSeconds'=>30,'splitToleranceW'=>100.],$m,$now];
}
function take(array $c,array $m,int $now):array{return B::aufnehmen($c,static fn($id)=>$m[$id],$now);}
[$c,$m,$n]=fixture();$r=take($c,$m,$n);
check($r['baseLoadCandidateW']===3000.,'subtract physical battery once, not virtual twice');
check($r['externalEffectW']===1000. && $r['allocationResidualW']===0.,'SDL kept outside household');
check($r['reconstructedGridW']===1000. && $r['arithmeticBalanceErrorW']===0.,'signed PCC balance closes');
check($r['controlEligible']===false && !$r['meteringBoundaryVerified'],'algebra never grants control');
check(!$r['forecastHistoryMigrated'],'old forecasts never relabelled');
$m[101]['value']=-5000.;$m[103]['value']=-3000.;$m[104]['value']=-2000.;$m[105]['value']=-1000.;$r=take($c,$m,$n);
check($r['baseLoadCandidateW']===3000. && $r['externalEffectW']===-1000.,'discharge and export signs correct');
[$c,$m,$n]=fixture();$m[103]['value']=2000.;$m[104]['value']=42000.;$m[105]['value']=-40000.;$r=take($c,$m,$n);
check($r['allocationResidualW']===0. && $r['baseLoadCandidateW']===4000.,'opposed virtual accounts are not two physical loads');
[$c,$m,$n]=fixture();$m[104]['value']=0.;$m[105]['value']=0.;$r=take($c,$m,$n);
check($r['externalEffectW']===3000. && $r['baseLoadCandidateW']===3000.,'held virtual values cannot contaminate base load');
check(in_array('virtual_physical_allocation_mismatch',$r['issues'],true),'split discrepancy explicit');
[$c,$m,$n]=fixture();$m[103]['updated']=$n-120;$r=take($c,$m,$n);
check(in_array('stale_origin:physical_storage',$r['issues'],true),'old physical reading rejected despite new acquisition');
check(in_array('stale_origin:controlled_virtual',$r['issues'],true),'fresh virtual timestamp cannot mask old physical input');
check($r['sources']['controlled_virtual']['oldestOriginAt']===$n-120,'original dependency timestamp preserved');
check($r['status']==='quality_hold','bad quality remains a diagnostic, not a training sample');
[$c,$m,$n]=fixture();$m[102]['updated']=$n-31;$r=take($c,$m,$n);check(in_array('source_time_skew',$r['issues'],true),'skew rejected separately from age');
$m[102]['updated']=$n+1;$r=take($c,$m,$n);check(in_array('future_source:pv',$r['issues'],true),'future timestamp rejected');
[$c,$m,$n]=fixture();foreach($m as &$v)$v['value']=0.;unset($v);$r=take($c,$m,$n);check($r['status']==='consistent_candidate' && $r['baseLoadCandidateW']===0.,'true all-zero observation accepted without fake history');
[$c,$m,$n]=fixture();$m[101]['value']=-6000.;$r=take($c,$m,$n);check($r['baseLoadCandidateW']===-4000. && in_array('negative_base_load',$r['issues'],true),'negative residual not silently clipped to zero');
[$c,$m,$n]=fixture();$m[102]['value']=-10.;check(in_array('negative_unsigned_source:pv',take($c,$m,$n)['issues'],true),'PV sign validity');
[$c,$m,$n]=fixture();$m[103]['value']=-3000.;$c['sources'][2]['factorToW']=-1.;check(take($c,$m,$n)['baseLoadCandidateW']===3000.,'explicit vendor sign conversion');
[$c,$m,$n]=fixture();$m[101]['value']=1.;$c['sources'][0]['factorToW']=1000.;check(take($c,$m,$n)['baseLoadCandidateW']===3000.,'kW to W factor applied once');
[$c,$m,$n]=fixture();$c['sources'][]=['key'=>'boiler','role'=>'flexible_load','variableId'=>200,'parentId'=>10,'ident'=>'boiler','factorToW'=>1.,'maxAgeSeconds'=>60,'dependsOn'=>[]];$m[200]=['value'=>500.,'updated'=>$n,'parentID'=>10,'ident'=>'boiler'];$r=take($c,$m,$n);check($r['baseLoadCandidateW']===2500. && $r['reconstructedGridW']===1000.,'future flexible load excluded exactly once');
foreach([true,'1000',NAN,INF] as $bad){[$c,$m,$n]=fixture();$m[101]['value']=$bad;fails(fn()=>take($c,$m,$n),'invalid numeric input '.gettype($bad));}
[$c,$m,$n]=fixture();$m[101]['parentID']=99;fails(fn()=>take($c,$m,$n),'different source parent rejected');
[$c,$m,$n]=fixture();$m[101]['ident']='other';fails(fn()=>take($c,$m,$n),'different source ident rejected');
[$c,$m,$n]=fixture();$c['sources'][]=$c['sources'][0];fails(fn()=>take($c,$m,$n),'duplicate physical source rejected');
[$c,$m,$n]=fixture();$c['sources'][3]['dependsOn']=['absent'];fails(fn()=>take($c,$m,$n),'missing origin rejected');
[$c,$m,$n]=fixture();$c['sources'][2]['dependsOn']=['controlled_virtual'];fails(fn()=>take($c,$m,$n),'dependency loop rejected');
[$c,$m,$n]=fixture();$a=take($c,$m,$n)['mappingId'];$c['sources']=array_reverse($c['sources']);check(take($c,$m,$n)['mappingId']===$a,'source order does not change mapping identity');
$c['sources'][0]['factorToW']=2.;check(take($c,$m,$n)['mappingId']!==$a,'factor changes invalidate old mapping identity');
[$c,$m,$n]=fixture();$calls=[];
fails(function()use($c,$m,$n,&$calls){B::aufnehmen($c,function($id)use($m,&$calls){$calls[$id]=($calls[$id]??0)+1;$v=$m[$id];if($calls[$id]>1)$v['value']++;return $v;},$n);},'same-second concurrent value change rejected');
$source=file_get_contents(__DIR__.'/../../libs/NetzfahrplanV4Bilanzierung.php');check(!preg_match('/\b(?:SetValue|IPS_RequestAction|RequestAction|curl_exec|IPS_SetProperty|AC_SetLoggingStatus)\s*\(/',$source),'adapter has no runtime or external write calls');
[$c,$m,$n]=fixture();$integer=$c;$integer['splitToleranceW']=100;
check(B::konfigurieren($c)===B::konfigurieren($integer),'integer and float tolerance normalize identically');
check(take($c,$m,$n)['mappingId']===take($integer,$m,$n)['mappingId'],'equal tolerance gives identical mapping hash');
$roundtrip=json_decode(json_encode($c,JSON_THROW_ON_ERROR),true,64,JSON_THROW_ON_ERROR);
check(take($roundtrip,$m,$n)['mappingId']===take($c,$m,$n)['mappingId'],'plain JSON property roundtrip preserves mapping');
$normalized=B::konfigurieren($c);check(B::konfigurieren($normalized)===$normalized,'normalization remains idempotent');
check(is_int($integer['splitToleranceW']) && is_float($c['splitToleranceW']),'input configurations are not mutated');
$changed=$c;$changed['splitToleranceW']=101;
check(take($changed,$m,$n)['mappingId']!==take($c,$m,$n)['mappingId'],'real tolerance change still changes identity');
foreach([true,'100'] as $invalid){$bad=$c;$bad['splitToleranceW']=$invalid;fails(fn()=>B::konfigurieren($bad),'non-numeric tolerance remains rejected '.gettype($invalid));}
echo "TOTAL $count checks passed; synthetic snapshots only.\n";
@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
require_once __DIR__.'/../../libs/NetzfahrplanV4Datenarchiv.php';
use Belevo\EnelixEMS\NetzfahrplanV4Datenarchiv as A;
$n=0;$now=1791028800;
function checkArchive(bool $x,string $why):void {global $n;if(!$x)throw new RuntimeException('FAIL '.$why);$n++;echo 'PASS '.$why."\n";}
function cleanupArchive(string $dir):void {foreach(new RecursiveIteratorIterator(new RecursiveDirectoryIterator($dir,FilesystemIterator::SKIP_DOTS),RecursiveIteratorIterator::CHILD_FIRST)as$p){$p->isDir()&&!$p->isLink()?rmdir($p->getPathname()):unlink($p->getPathname());}rmdir($dir);}
$base=sys_get_temp_dir().'/v4-archive-'.bin2hex(random_bytes(6));mkdir($base,0700);
try {
$old='raw-'.gmdate('Ymd',$now-10*86400).'.jsonl';$recent='raw-'.gmdate('Ymd',$now-2*86400).'.jsonl';$day='raw-'.gmdate('Ymd',$now).'.jsonl';
$raw="{\"sample\":1}\n{\"sample\":2}\n";file_put_contents($base.'/'.$old,$raw);file_put_contents($base.'/'.$recent,$raw);file_put_contents($base.'/'.$day,$raw);
checkArchive(A::run($base,[],$now)['archived']===0,'no acknowledgement means no archival');
$r=A::run($base,['day'=>$old,'offset'=>5],$now);checkArchive($r['archived']===0&&is_file($base.'/'.$old),'partially acknowledged day retained');
$r=A::run($base,['day'=>$day,'offset'=>5],$now);checkArchive($r['archived']===1&&!is_file($base.'/'.$old),'only completely acknowledged old day retired');
$files=glob($base.'/archive/*.gz');checkArchive(count($files)===1&&gzdecode(file_get_contents($files[0]))===$raw,'compressed original verified byte for byte');
checkArchive(is_file($base.'/'.$recent)&&is_file($base.'/'.$day),'recent and current outbox preserved');
checkArchive(A::run($base,['day'=>$day,'offset'=>5],$now)['archived']===0,'repeat is idempotent');
file_put_contents($base.'/'.$old,$raw);file_put_contents($files[0],gzencode('corrupt'));
try {A::run($base,['day'=>$day,'offset'=>5],$now);throw new LogicException('expected refusal');} catch(RuntimeException $e) {checkArchive(is_file($base.'/'.$old),'archive corruption never loses original');}
unlink($files[0]);$lock=fopen($base.'/.writer.lock','c+b');flock($lock,LOCK_EX);
checkArchive(A::run($base,['day'=>$day,'offset'=>5],$now)['status']==='writer_busy','archive serializes with actual capture writer');flock($lock,LOCK_UN);fclose($lock);
foreach(range(11,15)as$d)file_put_contents($base.'/raw-'.gmdate('Ymd',$now-$d*86400).'.jsonl',$raw);
checkArchive(A::run($base,['day'=>$day,'offset'=>5],$now)['archived']===2,'bounded archive work per invocation');
$text=file_get_contents(__DIR__.'/../../libs/NetzfahrplanV4Datenarchiv.php');checkArchive(!preg_match('/\b(?:RequestAction|IPS_SetProperty|curl_exec)\s*\(/',$text),'archive has no control or network calls');
echo "TOTAL $n archive checks passed; only temporary test data.\n";
} finally {cleanupArchive($base);}
+96
View File
@@ -0,0 +1,96 @@
<?php
declare(strict_types=1);
namespace Belevo\EnelixEMS;
require_once __DIR__.'/../../libs/ManagerNetzfahrplanV4DatenTrait.php';
$checks=0;$clock=1791028800;$sent=[];$mode='ok';$sensor=[];$locks=[];
$base=sys_get_temp_dir().'/v4-app-data-'.bin2hex(random_bytes(5));mkdir($base,0700);
function check(bool $v,string $name):void {global $checks;if(!$v)throw new \RuntimeException('FAIL '.$name);$checks++;echo 'PASS '.$name."\n";}
function time():int {global $clock;return $clock;}
function IPS_GetKernelDir():string {global $base;return $base.'/';}
function IPS_SemaphoreEnter($key,$timeout):bool {global $locks;if($locks[$key]??false)return false;$locks[$key]=true;return true;}
function IPS_SemaphoreLeave($key):void {global $locks;$locks[$key]=false;}
function IPS_VariableExists($id):bool {global $sensor;return isset($sensor[$id]);}
function IPS_GetVariable($id):array {global $sensor;return ['VariableType'=>2,'VariableUpdated'=>$sensor[$id]['updated'],'VariableChanged'=>$sensor[$id]['changed']];}
function IPS_GetObject($id):array {return ['ParentID'=>200,'ObjectIdent'=>'s'.$id];}
function GetValue($id) {global $sensor;return $sensor[$id]['value'];}
foreach(['CURLOPT_POST','CURLOPT_RETURNTRANSFER','CURLOPT_FOLLOWLOCATION','CURLOPT_CONNECTTIMEOUT','CURLOPT_TIMEOUT','CURLOPT_SSL_VERIFYPEER','CURLOPT_SSL_VERIFYHOST','CURLOPT_HTTPHEADER','CURLOPT_POSTFIELDS','CURLINFO_HTTP_CODE'] as $i=>$k)if(!defined($k))define($k,$i+100);
function curl_init($url){$o=new \stdClass();$o->url=$url;return $o;}
function curl_setopt_array($h,$options){$h->options=$options;return true;}
function curl_exec($h){global $sent,$mode;$p=json_decode($h->options[CURLOPT_POSTFIELDS],true);$sent[]=$p;
return json_encode(['status'=>'stored','datasetId'=>$p['datasetId'],'acceptedThrough'=>$mode==='wrong_ack'?'2000-01-01T00:00:00+00:00':end($p['records'])['capturedAt']]);}
function curl_getinfo($h,$info){global $mode;return $mode==='429'?429:200;}
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 function boot():void {$this->registriereNetzfahrplanV4Daten();}
public function configure():void {$this->konfiguriereNetzfahrplanV4Daten();}
public function folder():string {return $this->v4DatenVerzeichnis();}
public function send():array {return $this->v4DatenUebertragen($this->folder());}
public function RegisterPropertyBoolean($k,$v){$this->p[$k]??=$v;}
public function RegisterPropertyString($k,$v){$this->p[$k]??=$v;}
public function RegisterAttributeString($k,$v){$this->a[$k]??=$v;}
public function RegisterAttributeInteger($k,$v){$this->a[$k]??=$v;}
public function RegisterVariableString($k,...$args){$this->vars[$k]='';}
public function RegisterTimer($k,$v,$code){$this->timers[$k]=$v;}
public function SetTimerInterval($k,$v){$this->timers[$k]=$v;}
public function ReadPropertyBoolean($k){return $this->p[$k];}
public function ReadPropertyString($k){return $this->p[$k];}
public function ReadAttributeString($k){return $this->a[$k];}
public function ReadAttributeInteger($k){return $this->a[$k];}
public function WriteAttributeString($k,$v){$this->a[$k]=$v;}
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;}
}
$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'=>[]];
foreach(['grid','pv','physical_storage','controlled_virtual','external_virtual'] as $i=>$role){$id=101+$i;
$c['accounting']['sources'][]=['key'=>'s'.$id,'role'=>$role,'variableId'=>$id,'parentId'=>200,'ident'=>'s'.$id,'factorToW'=>1,'maxAgeSeconds'=>60,'dependsOn'=>[]];
$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->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');
check($sent[0]['records'][0]['raw']['s101']['value']===3000,'physical reading retained');
check($sent[0]['records'][0]['raw']['s101']['sourceUpdatedAt']===$clock,'original timestamp retained');
check(!str_contains(json_encode($sent),'synthetic-not-a-real-token'),'token never included in data payload');
check($sent[0]['records'][0]['controlEligible']===false,'data delivery is not actuator permission');
$expected=hash('sha256',json_encode(NetzfahrplanV4Messaufnahme::configuration($c),JSON_THROW_ON_ERROR|JSON_PRESERVE_ZERO_FRACTION));
check($sent[0]['records'][0]['mappingSha256']===$expected,'property roundtrip preserves approved mapping identity');
check(json_decode($m->a['NetzfahrplanV4LetzteDatenquittung'],true)['acceptedThrough']===end($sent[0]['records'])['capturedAt'],'successful acknowledgement persisted independently');
$clock+=30;$m->ErfasseNetzfahrplanV4Daten();check(count($sent)===1,'30-second collection does not double request rate');
check($m->a['NetzfahrplanV4DatenCursor']===$cursor,'unsent rows remain behind cursor');
check(isset(json_decode($m->vars['NetzfahrplanV4Datenstatus'],true)['lastAcknowledgement']['acceptedThrough']),'scheduled status retains last successful receipt');
$clock+=30;$mode='429';$m->ErfasseNetzfahrplanV4Daten();check($m->a['NetzfahrplanV4DatenCursor']===$cursor,'429 never advances acknowledgement cursor');
check(json_decode($m->vars['NetzfahrplanV4Datenstatus'],true)['delivery']['dataRetained'],'offline data retained');
check(json_decode($m->vars['NetzfahrplanV4Datenstatus'],true)['delivery']['httpStatus']===429,'safe HTTP error code visible without credentials');
$mode='wrong_ack';$clock+=120;$m->ErfasseNetzfahrplanV4Daten();check($m->a['NetzfahrplanV4DatenCursor']===$cursor,'wrong ack timestamp rejected');
$mode='ok';$clock+=240;$m->ErfasseNetzfahrplanV4Daten();check($m->a['NetzfahrplanV4DatenCursor']!==$cursor,'later success sends the preserved backlog');
check(count(end($sent)['records'])===4,'all previously unacknowledged rows included once');
$file=glob($m->folder().'/raw-*.jsonl')[0];$count=count(file($file));check($count===5,'raw journal never cleared by upload');
$lastCursor=$m->a['NetzfahrplanV4DatenCursor'];file_put_contents($file,'incomplete',FILE_APPEND);
check($m->send()['status']==='up_to_date','in-progress tail not transmitted');check($m->a['NetzfahrplanV4DatenCursor']===$lastCursor,'partial tail not acknowledged');
// Only temporary test journal: turn the prior incomplete tail into an additional complete queued record.
$buf=file_get_contents($file);$buf=substr($buf,0,-strlen('incomplete'));
$extra=$sent[0]['records'][0];$clock+=60;$extra['capturedAt']=gmdate('c',$clock);$extra['captureStartedAt']=$extra['capturedAt'];
file_put_contents($file,$buf.json_encode($extra)."\n".'crash-tail');
$clock+=60;$beforeCount=count($sent);$m->ErfasseNetzfahrplanV4Daten();
$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');
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');
echo "TOTAL $checks application-data checks passed; mocked IPS and HTTP only.\n";
} finally {
foreach(new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($base,\FilesystemIterator::SKIP_DOTS),\RecursiveIteratorIterator::CHILD_FIRST)as$p){$p->isDir()&&!$p->isLink()?rmdir($p->getPathname()):unlink($p->getPathname());}rmdir($base);
}
@@ -0,0 +1,30 @@
<?php
// Included only by the offline harness. No device or network calls.
[$m,$b,$s]=fixture();start($m,$s);$m->op['gridW']=3000.;tick($m);
check(end($b->writes)===-3000,'discharge sign and current net load compensation');
$b->measurements['Ladezustand']=15.;$b->watchdog();check(end($b->writes)===0,'reserve stops an active discharge independently');
[$m,$b,$s]=fixture();start($m,$s);$m->op['gridW']=3000.;tick($m);$b->state['HystereseAktiv']=true;$b->watchdog();
check(end($b->writes)===0,'local discharge hysteresis overrides previous command');
[$m,$b,$s]=fixture();start($m,$s);$m->op['gridW']=3000.;tick($m);$b->measurements['MaxEntladen']=500.;$b->watchdog();
check(end($b->writes)===-500,'discharge availability decrease is immediate');
[$m,$b,$s]=fixture();start($m,$s);tick($m);$b->measurements['Ladezustand']=2.;$b->watchdog();
check(!$b->active()&&end($b->writes)===0,'technical minimum violation aborts rather than inventing SOC');
[$m,$b,$s]=fixture();$s['maxChargeW']=1000.;start($m,$s);tick($m);
check(end($b->writes)===1000,'independent local cap can be stricter than server cap');
[$m,$b,$s]=fixture();start($m,$s);$b->attrs['LetzterSollwert']=500;$b->attrs['AenderungFreigabeAb']=$clock+4;tick($m);
check(end($b->writes)===500,'switch lock holds power increase');
advance(5);tick($m);check(end($b->writes)===3000,'power increase only after switching lock');
[$m,$b,$s]=fixture();start($m,$s);$b->attrs['LetzterSollwert']=500;$b->attrs['AenderungFreigabeAb']=$clock+4;$m->op['gridW']=3000.;tick($m);
check(end($b->writes)===0,'sign reversal first passes zero during switching lock');
[$m,$b,$s]=fixture();start($m,$s);$b->attrs['LetzterSollwert']=3000;$b->attrs['AenderungFreigabeAb']=$clock+4;$b->measurements['MaxLaden']=100.;tick($m);
check(end($b->writes)===100,'safety reduction not delayed by switching lock');
[$m,$b,$s]=fixture();start($m,$s);tick($m);$b->measurements['Ladezustand']=15.;$b->props['NachladenMitMaximalleistung']=false;$b->props['MaximaleNachladeleistung']=200;$b->watchdog();
check(end($b->writes)===200,'configured reserve-recovery charging limit remains enforced');
[$m,$b,$s]=fixture();start($m,$s);tick($m);$m->licensed=false;tick($m);
check(!$m->active()&&!$b->active()&&end($b->writes)===0,'license withdrawal revokes both software sessions');
[$m,$b,$s]=fixture();$s['durationSeconds']=30;start($m,$s);tick($m);advance(30);tick($m);
check(!$m->active()&&!$b->active()&&end($b->writes)===0,'local shorter test expiration respected');
[$m,$b,$s]=fixture();start($m,$s);tick($m);$m->vars['Aktiv']=false;tick($m);
check(!$m->active()&&!$b->active(),'manual manager disable ends trial');
[$m,$b,$s]=fixture();start($m,$s);tick($m);$b->vars['Aktiv']=false;$b->watchdog();
check(!$b->active()&&end($b->writes)===0,'manual battery disable independently ends trial');
+20
View File
@@ -0,0 +1,20 @@
<?php
[$m,$b,$s]=fixture();start($m,$s);tick($m);
$before=$b->cachedFeedback();$reads=$GLOBALS['deviceReads'];
$GLOBALS['locks']['ENELIX.V4.Feedback.44234']=true;
$b->watchdogOnly();
check($b->active() && $GLOBALS['deviceReads']===$reads,'busy sampler cannot block watchdog or trigger device I/O');
check($b->cachedFeedback()===$before,'cache preserves original confirmed timestamps and values');
$b->V4BatterieTestStoppen();
check(!$b->active() && end($b->writes)===0,'stop reaches zero while the independent sampler is busy');
[$m,$b,$s]=fixture();start($m,$s);tick($m);advance(3);$b->watchdogOnly();
check(!$b->active() && end($b->writes)===0,'stale sample revokes the command after two seconds');
[$m,$b,$s]=fixture();start($m,$s);tick($m);$GLOBALS['mono']+=2.01;$b->watchdogOnly();
check(!$b->active() && end($b->writes)===0,'monotonic sample age blocks frozen wall clock');
[$m,$b,$s]=fixture();start($m,$s);tick($m);$GLOBALS['clock']--;$b->watchdogOnly();
check(!$b->active() && end($b->writes)===0,'future cache timestamp is never made current');
[$m,$b,$s]=fixture();start($m,$s);tick($m);$deviceReadOk=false;$b->sample();
rejects(fn()=>$b->cachedFeedback(),'failed acquisition invalidates a still-young cached sample');
[$m,$b,$s]=fixture();start($m,$s);tick($m);$reads=$GLOBALS['deviceReads'];
$c=$m->command();$c['sequence']++;$b->V4BatterieTestBefehl(json_encode($c));
check($b->active() && $GLOBALS['deviceReads']===$reads,'command independently validates cache and sends without transport I/O');
+257
View File
@@ -0,0 +1,257 @@
<?php
// CLI ONLY. All IPS functions and register writes below are synthetic; no live kernel/network.
namespace Belevo\EnelixEMS {
if (\function_exists('IPS_GetKernelVersion')) throw new \RuntimeException('Offline test must NEVER run in Symcon.');
require_once __DIR__ . '/../../libs/ManagerNetzfahrplanV4TestTrait.php';
require_once __DIR__ . '/../../libs/ManagerNetzfahrplanV4AktivtestTrait.php';
require_once __DIR__ . '/../../libs/BatterieNetzfahrplanV4TestTrait.php';
class Lizenzpruefung { public const NETZFAHRPLAN='grid_schedule'; }
class Nachrichtenvertrag { public const LEISTUNGSQUELLE_GEMESSEN='measured'; }
function time() { return $GLOBALS['clock']; }
function hrtime($asNumber=true) { return (int)($GLOBALS['mono']*1e9); }
function IPS_SemaphoreEnter($key,$wait) { if (!empty($GLOBALS['locks'][$key])) return false;$GLOBALS['locks'][$key]=true;return true; }
function IPS_SemaphoreLeave($key) { unset($GLOBALS['locks'][$key]); }
function IPS_InstanceExists($id) { return $id===200 || isset($GLOBALS['objects'][$id]); }
function IPS_GetInstance($id) {
if ($id===200) return ['InstanceStatus'=>102,'ModuleInfo'=>['ModuleID'=>'{90BF9234-B44C-7EB3-280B-FBD6075ADDAA}']];
return ['InstanceStatus'=>$GLOBALS['objects'][$id]->ready?102:201,
'ModuleInfo'=>['ModuleID'=>$id===44234?'{437FB683-517F-4FEC-8CCB-FE6B0A62B69E}':'{6F771B18-59D4-4C8A-B951-3B2FE9F6A2C4}']];
}
function ModBus_RequestRead($id) {
if (!empty($GLOBALS['locks']['ENELIX.V4.Battery.44234'])) throw new \LogicException('Device I/O under battery control lock');
$GLOBALS['deviceReads'] = ($GLOBALS['deviceReads'] ?? 0) + 1;
$GLOBALS['clock'] += $GLOBALS['readDelay'] ?? 0;
return $id===200 && ($GLOBALS['deviceReadOk']??true);
}
function IPS_GetProperty($id,$key) { return $GLOBALS['objects'][$id]->props[$key]; }
function IPS_VariableExists($id) { return in_array($id,[50001,50002],true); }
function IPS_GetVariable($id) { $t=$GLOBALS['feedbackAt'][$id]??$GLOBALS['clock'];return ['VariableUpdated'=>$t,'VariableChanged'=>$t]; }
function IPS_GetObject($id) { return ['ParentID'=>200,'ObjectIdent'=>'source'.$id]; }
function GetValue($id) { return $id===50001?$GLOBALS['objects'][17004]->op['gridW']:$GLOBALS['objects'][17004]->actual['b']; }
function IPS_RequestAction($id,$key,$value) {
$GLOBALS['calls'][]=[$id,$key];$b=$GLOBALS['objects'][$id];
if (($GLOBALS['actionRefusal']??null)===$key) return false;
if ($key==='NetzfahrplanV4TestStart') $GLOBALS['readDelay']=$GLOBALS['startReadDelay']??0;
if ($key==='NetzfahrplanV4TestStart') $b->V4BatterieTestStarten($value);
elseif ($key==='NetzfahrplanV4TestCommand') $b->V4BatterieTestBefehl($value);
elseif ($key==='NetzfahrplanV4TestStop') $b->V4BatterieTestStoppen();
else throw new \RuntimeException('Unexpected external action in offline test');
return true;
}
trait StoreHarness {
public array $props=[],$attrs=[],$buf=[],$timers=[],$vars=['Aktiv'=>true];
public bool $ready=true;
private function RegisterPropertyBoolean($k,$v) { $this->props[$k]??=$v; }
private function RegisterPropertyInteger($k,$v) { $this->props[$k]??=$v; }
private function RegisterPropertyString($k,$v) { $this->props[$k]??=$v; }
private function RegisterAttributeString($k,$v) { $this->attrs[$k]??=$v; }
private function RegisterAttributeInteger($k,$v) { $this->attrs[$k]??=$v; }
private function RegisterVariableBoolean($k,$caption,$profile,$position) { $this->vars[$k]??=false; }
private function EnableAction($k) {}
private function RegisterTimer($k,$ms,$script) { $this->timers[$k]=$ms; }
private function SetTimerInterval($k,$ms) { $this->timers[$k]=$ms; }
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 ReadPropertyFloat($k) { return (float)$this->props[$k]; }
private function ReadAttributeString($k) { return $this->attrs[$k]??''; }
private function ReadAttributeInteger($k) { return $this->attrs[$k]??0; }
private function WriteAttributeString($k,$v) { $this->attrs[$k]=$v; }
private function WriteAttributeInteger($k,$v) { $this->attrs[$k]=$v; }
private function GetBuffer($k) { return $this->buf[$k]??''; }
private function SetBuffer($k,$v) { $this->buf[$k]=$v; }
private function GetValue($k) { return $this->vars[$k]; }
private function SetValue($k,$v) { $this->vars[$k]=$v; }
}
class BatteryHarness {
use StoreHarness, BatterieNetzfahrplanV4TestTrait;
public int $InstanceID=44234;
public array $state=['HystereseAktiv'=>false],$writes=[],$measurements=[];
public bool $failRegisters=false;
public function __construct() {
$this->props=['Batteriemanagement'=>2,'ReserveLadezustand'=>15.,'MindestLadezustand'=>3.,
'NachladenMitMaximalleistung'=>true,'MaximaleNachladeleistung'=>1000,
'LadezustandVariableID'=>1001,'IstleistungVariableID'=>1002];
$this->measurements=['Gueltig'=>true,'Ladezustand'=>50.,'MaxLaden'=>39000.,'MaxEntladen'=>39000.];
$this->attrs['LetzterSollwert']=0;$this->attrs['AenderungFreigabeAb']=0;
$this->registriereV4BatterieTest();
}
private function zugeordneteManagerIDs() { return [17004]; }
private function setzeZustand($k,$v) { $this->state[$k]=$v; }
private function leseZustand($k) { return $this->state[$k]??false; }
private function schreibeRegister($w,$force) { if ($this->failRegisters) throw new \RuntimeException('synthetic register failure');$this->writes[]=$w;$this->attrs['LetzterSollwert']=$w; }
private function aktualisiereIntern($notify) { $w=$this->v4BatterieTestZiel($this->measurements,$this->vars['Aktiv'],time());if ($w!==null) $this->schreibeRegister($w,false); }
public function sample() { try { $this->aktualisiereV4Rueckmeldung(); } catch (\Throwable) {} }
public function watchdog() { $this->sample(); $this->aktualisiereIntern(true); }
public function watchdogOnly() { $this->aktualisiereIntern(true); }
public function cachedFeedback() { return $this->v4BatterieRueckmeldung(); }
public function active() { return $this->v4BatterieTestAktiv(); }
}
class ManagerHarness {
use StoreHarness, ManagerNetzfahrplanV4TestTrait, ManagerNetzfahrplanV4AktivtestTrait;
public int $InstanceID=17004;
public array $op=[],$actual=['b'=>0.];public int $fallback=0;
public bool $licensed=true;
public function __construct($response,$op) {
$this->props=['NetzfahrplanV4SchattenAktiv'=>true,'NetzfahrplanV4EmpfangAktiv'=>true,'NetzfahrplanAktiv'=>false,
'AnlagenBatterien'=>json_encode([['ID'=>'b','SOCVariableID'=>1001,'LeistungVariableID'=>1002]])];
$this->op=$op;$this->attrs=['LizenzInstallationID'=>$response['installationId'],
'NetzfahrplanV4Empfang'=>json_encode(['response'=>$response,'receivedAt'=>time()])];
$this->registriereV4ManagerTest();
$this->registriereV4ManagerAktivtest();
}
private function berechtigungLizenziert($k) { return $this->licensed; }
private function actieveDummy() {}
private function aktiveVerbraucherIDs() { return [44234]; }
private function netzfahrplanV4LokaleVorschauwerte() {
$GLOBALS['objects'][44234]->sample(); // independent acquisition timer, outside control locks
$op=$this->op;$f=json_decode($GLOBALS['objects'][44234]->GetV4BatterieRueckmeldung(),true);
$op['localFeedback']=['b'=>$f];return [$op,$this->actual];
}
private function regeln($renew) { $this->fallback++; }
public function tick(&$allocation,$consumers=null,$sync=true) {
$this->v4ManagerTestUebergabe($allocation,$consumers??[['InstanzID'=>44234,'Daten'=>[]]],$sync);
}
public function active() { return (bool)$this->v4ManagerTestSession(); }
public function command() { return json_decode($this->attrs['NetzfahrplanV4RegeltestStatus'],true)['command']??null; }
}
}
namespace {
require_once __DIR__.'/../V4Receiver/fixture.php';
use Belevo\EnelixEMS\NetzfahrplanV4Regeltest as Gate;
use Belevo\EnelixEMS\BatteryHarness;
use Belevo\EnelixEMS\ManagerHarness;
$count=0;
function check($ok,$name) { global $count;if (!$ok) throw new RuntimeException('FAIL '.$name);$count++;echo "PASS $name\n"; }
function rejects(callable $call,$name) { try {$call();}catch(Throwable $e){check(true,$name);return;}throw new RuntimeException('FAIL did not reject '.$name); }
function fixture($permissions=true,$serverAuthority=true): array {
$GLOBALS['actionRefusal']=null;$GLOBALS['readDelay']=0;$GLOBALS['startReadDelay']=0;
[$r,$id,$op,$now]=receiverFixture();$GLOBALS['clock']=$now;$GLOBALS['mono']=1000.;$GLOBALS['locks']=[];$GLOBALS['calls']=[];$GLOBALS['deviceReadOk']=true;
$r['plan']['inputQuality']=['loadBasis'=>'base_load','accountingEvidenceId'=>'synthetic-meter-boundary-v1'];
$r['plan']['peakCostIsEstimate']=true;
$a=['kind'=>'controlled_trial_authority','version'=>1,'sourcePlanRemainsShadow'=>true,
'sessionId'=>'20000000-0000-4000-8000-000000000001','installationId'=>$id,
'managerId'=>17004,'batteryInstanceId'=>44234,'assetId'=>'b',
'sourceShadowPlanId'=>$r['plan']['planId'],'revision'=>1,'family'=>'3',
'issuedAt'=>gmdate('c',$now),'expiresAt'=>gmdate('c',$now+300),'checkedAt'=>gmdate('c',$now),'validUntil'=>gmdate('c',$now+90),
'maxChargeW'=>39000.,'maxDischargeW'=>39000.,'actuatorWatchdogEvidenceId'=>'synthetic-hardware-watchdog',
'accountingEvidenceId'=>'synthetic-meter-boundary-v1','acceptEstimatedPeak'=>true,'controlContext'=>$r['plan']['controlContext']];
$r['controlledTrial']=$serverAuthority?$a:null;
$m=new ManagerHarness($r,$op);$b=new BatteryHarness();$GLOBALS['objects']=[17004=>$m,44234=>$b];$GLOBALS['feedbackAt']=[];
$fc=['version'=>1,'installationId'=>$id,'assetId'=>'b','managerId'=>17004,'batteryInstanceId'=>44234,
'mode'=>'physical_sum','allowEstimatedForTrial'=>false,'maxSkewSeconds'=>30,'maxAgeSeconds'=>60,
'idleToleranceW'=>50.0,'trackingToleranceW'=>200.0,'sources'=>[]];
foreach ([50001=>'grid',50002=>'physical'] as $vid=>$role) $fc['sources'][]=['key'=>'source'.$vid,'role'=>$role,'variableId'=>$vid,'parentId'=>200,'ident'=>'source'.$vid,'factorToW'=>1.0];
$b->props['NetzfahrplanV4RueckmeldungKonfiguration']=json_encode($fc);
$op['localFeedback']=['b'=>json_decode($b->GetV4BatterieRueckmeldung(),true)];
$m->props['NetzfahrplanV4RegeltestErlaubt']=$permissions;$b->props['NetzfahrplanV4RegeltestErlaubt']=$permissions;
$b->props['NetzfahrplanV4GeraeteWatchdogNachweis']='synthetic-hardware-watchdog';
$start=['confirmation'=>'START_BOUNDED_V4_CONTROL_TRIAL','sessionId'=>$a['sessionId'],'durationSeconds'=>300,'maxChargeW'=>39000.,'maxDischargeW'=>39000.];
return [$m,$b,$start,$r,$op,$now];
}
function start($m,$s) {$m->V4ManagerTestStarten(json_encode($s));}
function tick($m) {$a=['Sollwerte'=>[44234=>999]];$m->tick($a);return $a;}
function advance($s) {$GLOBALS['clock']+=$s;$GLOBALS['mono']+=$s;}
function activeFixture(): array {
[$m,$b,$s,$r,$op,$now]=fixture();
$m->props['NetzfahrplanV4AktivtestErlaubt']=true;
$m->props['NetzfahrplanV4WatchdogVerzichtErlaubt']=true;
$b->props['NetzfahrplanV4AktivtestErlaubt']=true;
$b->props['NetzfahrplanV4WatchdogVerzichtErlaubt']=true;
return [$m,$b,$s,$r,$op,$now];
}
[$m,$b,$s]=fixture(false);rejects(fn()=>start($m,$s),'default local permission blocks');check($b->writes===[],'no register writes without consent');
[$m,$b,$s]=fixture(true,false);rejects(fn()=>start($m,$s),'ordinary shadow response never authorizes');check($calls===[],'no driver call for preview alone');
[$m,$b,$s]=fixture();$b->props['NetzfahrplanV4RegeltestErlaubt']=false;rejects(fn()=>start($m,$s),'second battery gate independent');
[$m,$b,$s]=fixture();$m->props['NetzfahrplanAktiv']=true;rejects(fn()=>start($m,$s),'old schedule excludes new controller');
[$m,$b,$s,$r]=fixture();$r['plan']['inputQuality']['loadBasis']='house_total';$m->attrs['NetzfahrplanV4Empfang']=json_encode(['response'=>$r,'receivedAt'=>$clock]);rejects(fn()=>start($m,$s),'unverified aggregate accounting blocks test');
[$m,$b,$s]=fixture();$b->props['NetzfahrplanV4GeraeteWatchdogNachweis']='';rejects(fn()=>start($m,$s),'unknown device watchdog blocks test');
[$m,$b,$s]=fixture();$m->ready=false;rejects(fn()=>start($m,$s),'unready manager blocks test');
[$m,$b,$s,$r]=fixture();
$r['plan']['points'][0]['gridTargetW']=25000.;$r['plan']['points'][0]['batteryTargetW']=28000.;
$r['plan']['points'][0]['assetTargetsW']['b']=28000.;$r['plan']['points'][0]['intent']='gridCharge';
$m->attrs['NetzfahrplanV4Empfang']=json_encode(['response'=>$r,'receivedAt'=>$clock]);
start($m,$s);tick($m);check(end($b->writes)===28000&&$m->command()['maxChargeW']===39000,'full dynamic plant power allowed above former 5kW test cap');
[$m,$b,$s]=fixture();start($m,$s);check($m->active()&&$b->active(),'both sessions armed');check($b->writes===[],'arm itself sends no register command');
$allocation=tick($m);check(!isset($allocation['Sollwerte'][44234]),'one actuator owner removes ordinary target');
check(end($b->writes)===3000,'actual trait chain delivers bounded 3kW command');
$c=$m->command();check($c['kind']==='controlled_trial_command'&&$c['expiresAt']===$clock+10,'independent command type and ten-second lease');
check(json_decode($b->GetV4BatterieTestStatus(),true)['physicalResponseVerified']===false,'controller acceptance is not physical proof');
advance(2);tick($m);check($m->command()['sequence']===2,'monotonic command sequence');
rejects(fn()=>$b->V4BatterieTestBefehl(json_encode($c)),'replayed command refused');check(!$b->active()&&end($b->writes)===0,'replay aborts and releases output');
[$m,$b,$s]=fixture();start($m,$s);tick($m);advance(11);$b->watchdog();check(!$b->active()&&end($b->writes)===0,'watchdog expiry drops to zero');
[$m,$b,$s]=fixture();start($m,$s);advance(11);$b->watchdog();check(!$b->active()&&end($b->writes)===0,'missing first command expires');
[$m,$b,$s]=fixture();start($m,$s);tick($m);$mono+=11;$b->watchdog();check(!$b->active()&&end($b->writes)===0,'monotonic watchdog works without wall clock advance');
[$m,$b,$s]=fixture();start($m,$s);tick($m);$clock-=1;$b->watchdog();check(!$b->active(),'backwards wall clock aborts');
[$m,$b,$s]=fixture();start($m,$s);tick($m);$m->V4ManagerTestStoppen();check(!$m->active()&&!$b->active()&&end($b->writes)===0&&$m->fallback===1,'manual stop releases before ordinary fallback');
rejects(fn()=>start($m,$s),'stopped local session cannot resurrect');
[$m,$b,$s]=fixture();start($m,$s);tick($m);$b->buf=[];$b->V4BatterieTestStoppen();check(end($b->writes)===0&&!$b->active(),'runtime restart clears persisted command marker');
[$m,$b,$s]=fixture();start($m,$s);tick($m);$b->measurements['Gueltig']=false;$b->watchdog();check(!$b->active()&&end($b->writes)===0,'invalid device measurements release command');
[$m,$b,$s]=fixture();start($m,$s);tick($m);$b->measurements['MaxLaden']=1000.;$b->watchdog();check(end($b->writes)===1000,'device availability reduction rechecked');
[$m,$b,$s]=fixture();start($m,$s);tick($m);$b->measurements['Ladezustand']=99.;$b->watchdog();check(end($b->writes)===0,'full battery cannot keep charging');
[$m,$b,$s]=fixture();start($m,$s);tick($m);$b->props['NetzfahrplanV4RegeltestErlaubt']=false;$b->watchdog();check(!$b->active()&&end($b->writes)===0,'battery consent withdrawn aborts');
[$m,$b,$s]=fixture();start($m,$s);tick($m);$m->props['NetzfahrplanV4RegeltestErlaubt']=false;$a=tick($m);check(!$m->active()&&isset($a['Sollwerte'][44234])&&end($b->writes)===0,'manager consent withdrawn resumes ordinary allocation');
[$m,$b,$s]=fixture();start($m,$s);tick($m);$m->attrs['NetzfahrplanV4Empfang']='{}';$a=tick($m);check(!$m->active()&&end($b->writes)===0,'invalidated receiver cache ends trial');
[$m,$b,$s]=fixture();start($m,$s);tick($m);$a=['Sollwerte'=>[44234=>123]];$m->tick($a,null,false);check(!$m->active()&&end($b->writes)===0,'unsynchronized consumers abort');
[$m,$b,$s]=fixture();start($m,$s);tick($m);$b->failRegisters=true;advance(2);tick($m);check(!$m->active()&&!$b->active(),'register failure revokes both sessions');check(json_decode($m->GetV4ManagerTestStatus(),true)['controllerStopAccepted']===false,'stop failure not disguised as safe');
[$m,$b,$s]=fixture();start($m,$s);$a=['Sollwerte'=>[44234=>999,33333=>1000]];$m->tick($a,[['InstanzID'=>44234,'Daten'=>[]],['InstanzID'=>33333,'Daten'=>['Leistungsquelle'=>'measured','Istleistung_W'=>0]]]);
check(end($b->writes)===2000&&$a['Sollwerte'][33333]===1000,'other consumer delta counted once without takeover');
[$m,$b,$s]=fixture();start($m,$s);$m->actual['b']=2000.;$m->op['gridW']=-1000.;tick($m);check(end($b->writes)===3000,'command is total battery power not additive');
[$m,$b,$s]=fixture();start($m,$s);$m->actual['b']=0.;$m->op['gridW']=-40000.;tick($m);check($m->active()&&end($b->writes)===39000,'full dynamic power corrects export while hard grid limit stays enforced');
[$m,$b,$s]=fixture();start($m,$s);tick($m);$clock+=91;$mono+=91;tick($m);check(!$m->active()&&end($b->writes)===0,'expired server observation cancels even with future prices');
[$m,$b,$s,$r,$op,$now]=fixture();$local=['enabled'=>true,'legacyEnabled'=>false,'installationId'=>$r['installationId'],'sessionId'=>$s['sessionId'],'managerId'=>17004,'batteryInstanceId'=>44234,'assetId'=>'b','expiresAt'=>$now+300,'maxChargeW'=>5000,'maxDischargeW'=>5000,'sequence'=>1,'actuatorWatchdogEvidenceId'=>'synthetic-hardware-watchdog'];
foreach (['sessionId','installationId','sourceShadowPlanId'] as $key) {
$bad=$r;$bad['controlledTrial'][$key]='ffffffff-ffff-4fff-8fff-ffffffffffff';rejects(fn()=>Gate::befehl($bad,$op,['b'=>0.],$local,$now,$now),'wrong authority binding '.$key);
}
$bad=$r;$bad['controlledTrial']['validUntil']=gmdate('c',$now+120);rejects(fn()=>Gate::befehl($bad,$op,['b'=>0.],$local,$now,$now),'oversized server authorization lease');
$bad=$r;$bad['controlledTrial']['acceptEstimatedPeak']=false;rejects(fn()=>Gate::befehl($bad,$op,['b'=>0.],$local,$now,$now),'peak uncertainty needs explicit acceptance');
[$m,$b]=activeFixture();$m->V4ManagerAktivtestSchalten(true);
check($m->active()&&$b->active()&&$m->vars['NetzfahrplanV4Aktivtest'],'single runtime switch starts bounded active test');
$allocation=tick($m);
check(end($b->writes)===3000&&!isset($allocation['Sollwerte'][44234]),'active test uses guarded command path');
check($m->command()['executionMeaning']==='explicit_time_boxed_test_plant_operation','active command is explicitly distinguished');
[$m2,$b2]=activeFixture();$m2->V4ManagerAktivtestSchalten(true);
$mixed=['Sollwerte'=>[44234=>999,33333=>1000]];
$m2->tick($mixed,[['InstanzID'=>44234,'Daten'=>[]],['InstanzID'=>33333,'Daten'=>['Leistungsquelle'=>'calculated','Istleistung_W'=>500]]],false);
check($m2->active()&&$b2->active()&&!isset($mixed['Sollwerte'][44234])&&!isset($mixed['Sollwerte'][33333]),'active test freezes other consumers and tolerates unrelated desync');
$GLOBALS['objects']=[17004=>$m,44234=>$b];
check($m->attrs['NetzfahrplanV4AktivtestEnde']-$m->attrs['NetzfahrplanV4AktivtestBeginn']===172800,'active test has fixed 48-hour window');
advance(91);
$GLOBALS['feedbackAt']=[50001=>$clock,50002=>$clock];
$m->op['observedAt']=gmdate('c',$clock);
$m->op['batteries'][0]['measuredAt']=gmdate('c',$clock);
$envelope=json_decode($m->attrs['NetzfahrplanV4Empfang'],true);
$envelope['receivedAt']=$clock;
$envelope['response']['checkedAt']=gmdate('c',$clock);
$m->attrs['NetzfahrplanV4Empfang']=json_encode($envelope);
tick($m);
check($m->active()&&$b->active(),'local active authority refreshes while plan and measurements remain valid');
$m->V4ManagerAktivtestSchalten(false);
check(!$m->active()&&!$b->active()&&!$m->vars['NetzfahrplanV4Aktivtest']&&end($b->writes)===0,'switch off revokes session and requests zero');
[$m,$b]=activeFixture();$m->props['NetzfahrplanV4WatchdogVerzichtErlaubt']=false;
rejects(fn()=>$m->V4ManagerAktivtestSchalten(true),'active test requires explicit watchdog waiver property');
[$m,$b]=activeFixture();$b->props['NetzfahrplanV4WatchdogVerzichtErlaubt']=false;
rejects(fn()=>$m->V4ManagerAktivtestSchalten(true),'battery independently requires explicit watchdog waiver property');
[$m,$b,$s]=fixture();$GLOBALS['actionRefusal']='NetzfahrplanV4TestStart';
rejects(fn()=>start($m,$s),'FALSE start result is rejected');
check(!$m->active()&&!$b->active(),'refused start cannot arm manager');
[$m,$b]=activeFixture();$GLOBALS['actionRefusal']='NetzfahrplanV4TestStart';
rejects(fn()=>$m->V4ManagerAktivtestSchalten(true),'FALSE active start result is rejected');
check(!$m->vars['NetzfahrplanV4Aktivtest']&&!$m->active(),'refused active start leaves switch off');
[$m,$b,$s]=fixture();start($m,$s);$GLOBALS['actionRefusal']='NetzfahrplanV4TestCommand';tick($m);
check(!$m->active()&&!$b->active()&&$m->command()===null,'FALSE command result aborts instead of reporting sent');
[$m,$b,$s]=fixture();start($m,$s);$GLOBALS['actionRefusal']='NetzfahrplanV4TestStop';$m->V4ManagerTestStoppen();
check(json_decode($m->GetV4ManagerTestStatus(),true)['controllerStopAccepted']===false,'FALSE stop result stays visibly unconfirmed');
[$m,$b,$s]=fixture();$GLOBALS['startReadDelay']=1;start($m,$s);
check($m->active()&&$b->active(),'battery arming uses an independently confirmed snapshot without locked I/O');
[$m,$b,$s]=fixture();start($m,$s);$b->measurements['Gueltig']=false;tick($m);
check(str_contains(json_decode($b->GetV4BatterieTestStatus(),true)['reason'],'Messwerte ungueltig'),
'command rejection preserves underlying safety reason after zero request');
require __DIR__ . '/additional_checks.php';
require __DIR__ . '/feedback_checks.php';
require __DIR__ . '/cache_checks.php';
echo "TOTAL $count checks passed. Mocked IPS, clocks and register writes only.\n";
}
+23
View File
@@ -0,0 +1,23 @@
<?php
// Run through actual Manager/Battery trial traits, fresh read helper, and simulated register driver.
[$m,$b,$s]=fixture();$b->props['NetzfahrplanV4RueckmeldungKonfiguration']='{}';
rejects(fn()=>start($m,$s),'trial cannot use old filtered feedback when mapping missing');check($b->writes===[],'missing corrected feedback sends no command');
[$m,$b,$s]=fixture();start($m,$s);tick($m);$deviceReadOk=false;
$a=tick($m);check(!$m->active()&&!$b->active()&&isset($a['Sollwerte'][44234]),'failed confirmed read returns battery to normal allocation');
check(end($b->writes)===0,'failed confirmed read revokes prior command');
[$m,$b,$s]=fixture();start($m,$s);tick($m);$deviceReadOk=false;$b->watchdog();
check(!$b->active()&&end($b->writes)===0,'independent battery read catches device failure without manager tick');
[$m,$b,$s]=fixture();start($m,$s);tick($m);$c=json_decode($b->props['NetzfahrplanV4RueckmeldungKonfiguration'],true);$c['trackingToleranceW']=250;
$b->props['NetzfahrplanV4RueckmeldungKonfiguration']=json_encode($c);$b->watchdog();
check(!$b->active()&&end($b->writes)===0,'mapping revision change revokes active battery session');
[$m,$b,$s]=fixture();start($m,$s);tick($m);$c=$m->command();$c['sequence']++;$c['feedbackConfigHash']=str_repeat('f',64);
rejects(fn()=>$b->V4BatterieTestBefehl(json_encode($c)),'changed feedback command binding rejected');
[$m,$b,$s]=fixture();start($m,$s);tick($m);$c=$m->command();check(isset($c['feedbackConfigHash'],$c['feedbackSourceOldestAt'])&&array_key_exists('importLimitW',$c),'command includes source provenance and limits');
$c['sequence']++;unset($c['exportLimitW']);rejects(fn()=>$b->V4BatterieTestBefehl(json_encode($c)),'missing explicit grid limit not interpreted as unlimited');
[$m,$b,$s]=fixture();start($m,$s);tick($m);$c=$m->command();$c['sequence']++;$c['importLimitW']=1000.;$c['watts']=0;
$m->op['gridW']=1500.;$b->measurements['Ladezustand']=15.;
$b->sample();
rejects(fn()=>$b->V4BatterieTestBefehl(json_encode($c)),'final device clipping cannot silently violate accepted grid boundary');
check(!$b->active()&&end($b->writes)===0,'unreachable final limit aborts software trial');
[$m,$b,$s]=fixture();start($m,$s);tick($m);$feedbackAt[50002]=$clock+1;tick($m);
check(!$m->active(),'future physical sample is not smoothed into control');
+42
View File
@@ -0,0 +1,42 @@
<?php
// CLI only: exercise the actual module method with synthetic variable actions.
if (function_exists('IPS_GetKernelVersion')) throw new RuntimeException('Offline test only.');
class IPSModule
{
public int $InstanceID = 0;
public array $props = [], $attrs = [];
protected function ReadPropertyInteger($key) { return $this->props[$key] ?? 0; }
protected function ReadPropertyBoolean($key) { return $this->props[$key] ?? false; }
protected function ReadAttributeInteger($key) { return $this->attrs[$key] ?? 0; }
protected function ReadAttributeBoolean($key) { return $this->attrs[$key] ?? false; }
protected function ReadAttributeString($key) { return $this->attrs[$key] ?? ''; }
protected function WriteAttributeInteger($key, $value) { $this->attrs[$key] = $value; }
protected function WriteAttributeBoolean($key, $value) { $this->attrs[$key] = $value; }
protected function WriteAttributeString($key, $value) { $this->attrs[$key] = $value; }
}
function IPS_GetObjectIDByIdent($ident, $parent) { return false; }
function RequestAction($id, $value) { $GLOBALS['writes'][] = [$id, $value]; return $GLOBALS['accepted']; }
require_once __DIR__ . '/../../Batterie/module.php';
$battery = new Batterie();
$battery->InstanceID = 44234;
$battery->props = [
'Batterietyp' => Belevo\EnelixEMS\BatterieRegler::TYP_GOODWE,
'Batteriemanagement' => Belevo\EnelixEMS\BatterieRegler::STEUERUNG_ENELIX,
'ManagementRegisterVariableID' => 1, 'ModusRegisterVariableID' => 2,
'LeistungsRegisterVariableID' => 3, 'Aenderungssperre' => 0,
];
$method = new ReflectionMethod(Batterie::class, 'schreibeRegister');
$accepted = false; $writes = [];
try { $method->invoke($battery, 12000, false); throw new LogicException('FALSE was accepted'); }
catch (RuntimeException $error) {
if (!str_contains($error->getMessage(), 'Registeraktion nicht bestaetigt')) throw $error;
}
if (($battery->attrs['RegisterInitialisiert'] ?? false) || ($battery->attrs['RegisterSchreiben'] ?? false)) {
throw new RuntimeException('Failed action cached or lock not released');
}
$accepted = true;
$method->invoke($battery, 12000, false);
if (($battery->attrs['LetzterSollwert'] ?? 0) !== 12000 || !$battery->attrs['RegisterInitialisiert']) {
throw new RuntimeException('Successful retry not cached');
}
echo "PASS actual module rejects FALSE register action, releases guard and retries successfully\n";
+62
View File
@@ -0,0 +1,62 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../../libs/NetzfahrplanV4Messaufnahme.php';
use Belevo\EnelixEMS\NetzfahrplanV4Messaufnahme as Capture;
$n = 0;
function ck(bool $ok, string $label): void { global $n; if (!$ok) throw new RuntimeException('FAIL ' . $label); $n++; echo 'PASS ' . $label . "\n"; }
function refuses(callable $f, string $label): void { try { $f(); } catch (Throwable $e) { ck(true,$label); return; } throw new RuntimeException('Expected refusal: '.$label); }
$now = 1790952000;
$roles=['grid','pv','physical_storage','controlled_virtual','external_virtual'];
$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'=>[['key'=>'soc','variableId'=>106,'parentId'=>200,'ident'=>'soc','unit'=>'percent']]];
$values=[3000,5000,2000,1500,500]; $readings=[];
foreach($roles as $i=>$role){
$key='s'.$i;$id=101+$i;
$c['accounting']['sources'][]=['key'=>$key,'role'=>$role,'variableId'=>$id,'parentId'=>200,'ident'=>$key,'factorToW'=>1.,'maxAgeSeconds'=>60,'dependsOn'=>in_array($i,[3,4],true)?['s2']:[]];
$readings[$id]=['value'=>$values[$i],'updated'=>$now,'changed'=>$now-30,'parentID'=>200,'ident'=>$key];
}
$readings[106]=['value'=>0.,'updated'=>$now,'changed'=>0,'parentID'=>200,'ident'=>'soc'];
function take(array $c,array $r,int $now): array { return Capture::capture($c,static fn(int $id):array=>$r[$id],static fn():int=>$now); }
$r=take($c,$readings,$now);
ck($r['assessment']['baseLoadCandidateW']===6000.,'physical battery subtracted exactly once');
ck($r['raw']['soc']['value']===0. && $r['raw']['soc']['sourceChangedAt']===0,'real zero and unchanged timestamp retained');
ck(!$r['trainingEligible'] && !$r['controlEligible'] && !$r['measurementBoundaryVerified'],'algebra never enables training or control');
ck($r['futureSdl']==='unknown_no_assumption','no fabricated future SDL');
ck(count($r['raw'])===6,'raw extra sources retained separately');
$a=$readings;$a[103]['updated']=$now-90;$a[104]['updated']=$now;
$x=take($c,$a,$now);ck($x['assessment']['status']==='quality_hold','old physical measurement quarantines candidate');
ck($x['raw']['s2']['sourceUpdatedAt']===$now-90,'original old timestamp preserved');
ck(in_array('stale_origin:s3',$x['assessment']['issues'],true),'fresh virtual write cannot mask old dependency');
$a=$readings;$a[101]['value']=null;$x=take($c,$a,$now);ck($x['raw']['s0']['value']===null && $x['assessment']===null,'missing source not zero');
$a=$readings;$a[101]['value']='sensitive-test-text';$x=take($c,$a,$now);ck(!str_contains(json_encode($x),'sensitive-test-text'),'unexpected strings never logged');
foreach([true,NAN,INF,1.0e20] as $v){$a=$readings;$a[101]['value']=$v;$x=take($c,$a,$now);ck($x['raw']['s0']['value']===null && is_string(json_encode($x,JSON_THROW_ON_ERROR)),'invalid numeric represented safely');}
$a=$readings;$a[101]['parentID']=999;$x=take($c,$a,$now);ck($x['raw']['s0']['value']===null && $x['assessment']===null,'reused ID not recorded as correct measurement');
$a=$readings;$a[102]['updated']=$now+1;$x=take($c,$a,$now);ck($x['assessment']===null && in_array('s1:future_source',$x['issues'],true),'future timestamp marked');
$a=$readings;$a[101]['updated']=0;$x=take($c,$a,$now);ck($x['assessment']===null,'absent timestamp not invented');
$counts=[];$x=Capture::capture($c,static function($id)use(&$counts,$readings){$r=$readings[$id];$counts[$id]=($counts[$id]??0)+1;if($id===101 && $counts[$id]===2)$r['value']++;return $r;},static fn()=>$now);
ck($x['assessment']===null && $x['raw']['s0']['secondValue']===3001,'same-second concurrent change visible');
$x=Capture::capture($c,static function($id)use($readings){if($id===101)throw new RuntimeException('do-not-emit-secret');return $readings[$id];},static fn()=>$now);
ck(!str_contains(json_encode($x),'do-not-emit-secret') && $x['assessment']===null,'source errors bounded without secret messages');
$a=$readings;$a[106]['value']=null;$x=take($c,$a,$now);ck($x['assessment']!==null && !$x['trainingEligible'],'optional SOC error does not destroy available raw powers');
$a=$readings;$a[101]['value']=-10000;$x=take($c,$a,$now);ck($x['assessment']['baseLoadCandidateW']<0 && $x['assessment']['status']==='quality_hold','negative candidate not clipped');
$a=$c;$a['extraSources'][]=$a['extraSources'][0];refuses(fn()=>take($a,$readings,$now),'duplicate source rejected');
$a=$c;$a['accounting']['sources']=array_reverse($a['accounting']['sources']);ck(take($a,$readings,$now)['mappingSha256']===$r['mappingSha256'],'mapping identity stable');
$t=0;refuses(fn()=>Capture::capture($c,static fn($id)=>$readings[$id],static function()use(&$t,$now){return $now-$t++;}),'backwards clock rejected');
$base=sys_get_temp_dir().'/v4-raw-test-'.bin2hex(random_bytes(5));mkdir($base,0700);$dir=$base.'/data';mkdir($dir,0700);
try{
$file=Capture::append($dir,$r);$first=file_get_contents($file);Capture::append($dir,$r);
ck(file_get_contents($file)===$first.$first,'append retains existing observations');
ck(count(file($file))===2,'one record per line');
ck((fileperms($file)&0777)===0640,'data permissions restricted');
$old=file_get_contents($file);refuses(fn()=>Capture::append($dir,$r,1),'quota refuses without deleting data');ck(file_get_contents($file)===$old,'quota leaves journal unchanged');
file_put_contents($file,'partial',FILE_APPEND);refuses(fn()=>Capture::append($dir,$r),'crash-truncated tail not silently extended');ck(str_ends_with(file_get_contents($file),'partial'),'incomplete record retained for review');
unlink($file);file_put_contents($base.'/foreign','untouched');symlink($base.'/foreign',$file);refuses(fn()=>Capture::append($dir,$r),'journal symlink refused');ck(file_get_contents($base.'/foreign')==='untouched','unrelated file preserved');unlink($file);
$lock=fopen($dir.'/.writer.lock','c+b');flock($lock,LOCK_EX);refuses(fn()=>Capture::append($dir,$r),'parallel writer refused');flock($lock,LOCK_UN);fclose($lock);
$a=$r;$a['blob']=str_repeat('x',300000);refuses(fn()=>Capture::append($dir,$a),'oversized record refused');
}finally{foreach(new RecursiveIteratorIterator(new RecursiveDirectoryIterator($base,FilesystemIterator::SKIP_DOTS),RecursiveIteratorIterator::CHILD_FIRST)as$p){$p->isDir()&&!$p->isLink()?rmdir($p->getPathname()):unlink($p->getPathname());}rmdir($base);}
$source=file_get_contents(__DIR__.'/../../libs/NetzfahrplanV4Messaufnahme.php');
ck(!preg_match('/\b(?:IPS_|GetValue|SetValue|curl_|RequestAction|shell_exec|exec\s*\()/',$source),'capture core has no device or network calls');
echo "TOTAL $n checks passed; synthetic values and temporary files only.\n";
+61
View File
@@ -0,0 +1,61 @@
<?php
declare(strict_types=1);
// ALL IPS functions below are mocks. Never execute this test in a real Symcon kernel.
if (PHP_SAPI !== 'cli') throw new RuntimeException('CLI isolation required');
require_once __DIR__ . '/../../examples/V4DataCapture/install.php';
$count=0;$objects=[];$values=[];$scripts=[];$timers=[];$nextId=60000;$locks=[];$timerCalls=[];
function yes(bool $v,string $s):void{global$count;if(!$v)throw new RuntimeException('FAIL '.$s);$count++;echo 'PASS '.$s."\n";}
function IPS_GetKernelVersion(){return '8.0';}
function IPS_GetObject($id){return $GLOBALS['objects'][$id]??throw new RuntimeException('Missing object');}
function IPS_GetChildrenIDs($id){return array_keys(array_filter($GLOBALS['objects'],static fn($o)=>$o['ParentID']===$id));}
function IPS_InstanceExists($id){return $id===17004;}
function IPS_GetInstance($id){return ['ModuleInfo'=>['ModuleID'=>'{6F771B18-59D4-4C8A-B951-3B2FE9F6A2C4}']];}
function IPS_VariableExists($id){return isset($GLOBALS['values'][$id]);}
function IPS_GetVariable($id){$v=$GLOBALS['values'][$id];return ['VariableType'=>is_string($v)?3:2,'VariableUpdated'=>time(),'VariableChanged'=>time()-10];}
function GetValue($id){return $GLOBALS['values'][$id];}
function SetValue($id,$v){if($id<60000||IPS_GetObject($id)['ObjectIdent']!=='CaptureStatus')throw new RuntimeException('Foreign variable write');$GLOBALS['values'][$id]=$v;return true;}
function IPS_SemaphoreEnter($key,$wait){if($GLOBALS['locks'][$key]??false)return false;$GLOBALS['locks'][$key]=true;return true;}
function IPS_SemaphoreLeave($key){$GLOBALS['locks'][$key]=false;}
function makeObj($type){$id=$GLOBALS['nextId']++;$GLOBALS['objects'][$id]=['ObjectType'=>$type,'ObjectIdent'=>'','ParentID'=>0,'ObjectInfo'=>''];return $id;}
function IPS_CreateCategory(){return makeObj(0);}
function IPS_CreateScript($type){$id=makeObj(3);$GLOBALS['scripts'][$id]='<?php';return $id;}
function IPS_CreateVariable($type){if($type!==3)throw new RuntimeException('Only string status allowed');$id=makeObj(2);$GLOBALS['values'][$id]='';return $id;}
function setObj($id,$key,$v){if($id<60000)throw new RuntimeException('Existing object modified');$GLOBALS['objects'][$id][$key]=$v;return true;}
function IPS_SetParent($id,$v){return setObj($id,'ParentID',$v);}
function IPS_SetIdent($id,$v){return setObj($id,'ObjectIdent',$v);}
function IPS_SetName($id,$v){return setObj($id,'ObjectName',$v);}
function IPS_SetInfo($id,$v){return setObj($id,'ObjectInfo',$v);}
function IPS_GetScriptContent($id){return $GLOBALS['scripts'][$id];}
function IPS_SetScriptContent($id,$v){if($id<60000)throw new RuntimeException('Foreign script write');$GLOBALS['scripts'][$id]=$v;return true;}
function IPS_SetScriptTimer($id,$seconds){if($id<60000||IPS_GetObject($id)['ObjectIdent']!=='RawCapture')throw new RuntimeException('Foreign timer changed');$GLOBALS['timers'][$id]=$seconds;$GLOBALS['timerCalls'][]=[$id,$seconds];return true;}
// A separate fake stage with real candidate code and known synthetic numeric sources.
$base=sys_get_temp_dir().'/v4-native-capture-'.bin2hex(random_bytes(5));mkdir($base,0700);mkdir($base.'/data',0700);
$repo=dirname(__DIR__,2);$names=['libs/NetzfahrplanV4Bilanzierung.php','libs/NetzfahrplanV4Messaufnahme.php','examples/V4DataCapture/runtime.php','examples/V4DataCapture/install.php'];
$hashes=[];
foreach($names as $name){$p=$base.'/source/'.$name;if(!is_dir(dirname($p)))mkdir(dirname($p),0700,true);copy($repo.'/'.$name,$p);$hashes['source/'.$name]=hash_file('sha256',$p);}
$info=['source'=>'synthetic_test','physicalKwh'=>322,'evKwh'=>160];file_put_contents($base.'/USER_REPORTED_INVENTORY.json',json_encode($info));
$c=['version'=>1,'installationId'=>'e3a08f9e-af12-4695-99bd-8b51c0520021','managerId'=>17004,'reportedInventorySha256'=>hash_file('sha256',$base.'/USER_REPORTED_INVENTORY.json'),
'extraSources'=>[],'accounting'=>['version'=>1,'maxSkewSeconds'=>30,'splitToleranceW'=>100.,'sources'=>[]]];
foreach(['grid','pv','physical_storage','controlled_virtual','external_virtual']as$i=>$role){$id=101+$i;$c['accounting']['sources'][]=['key'=>'x'.$i,'variableId'=>$id,'parentId'=>200,'ident'=>'v'.$i,'role'=>$role,'factorToW'=>1,'maxAgeSeconds'=>60,'dependsOn'=>[]];$objects[$id]=['ParentID'=>200,'ObjectIdent'=>'v'.$i,'ObjectType'=>2,'ObjectInfo'=>'original'];$values[$id]=[1000,2000,500,500,0][$i];}
file_put_contents($base.'/config.json',json_encode($c));$hashes['config.json']=hash_file('sha256',$base.'/config.json');$hashes['USER_REPORTED_INVENTORY.json']=hash_file('sha256',$base.'/USER_REPORTED_INVENTORY.json');
file_put_contents($base.'/MANIFEST.json',json_encode(['scope'=>'passive_raw_capture_only','hashes'=>$hashes]));
$before=$objects;$beforeValues=$values;
try{
ob_start();$r=v4captureInstall($base);ob_end_clean();
yes($r['status']==='capture_installed','passive installation completes');
yes(count($r['createdObjects'])===4,'only category, two scripts and one status variable');
yes($timers[$r['scriptId']]===30,'only new capture timer runs at 30 seconds');
yes($r['firstCapture']['status']==='recorded'&&count(file($r['firstCapture']['journal']))===1,'first capture actually written before activation');
yes($r['firstCapture']['accountingQuality']==='consistent_candidate','candidate is not a training permit');
foreach($before as$id=>$o){yes($objects[$id]===$o && $values[$id]===$beforeValues[$id],'existing source preserved '.$id);}
$size=count($objects);ob_start();$again=v4captureInstall($base);ob_end_clean();yes(count($objects)===$size&&$again['createdObjects']===[],'installation idempotent, no duplicate timers');
$bad=$scripts[$r['scriptId']];$scripts[$r['scriptId']]="<?php // concurrent edit\n";ob_start();$err=v4captureInstall($base);ob_end_clean();yes($err['status']==='needs_review'&&$scripts[$r['scriptId']]!==$bad,'parallel script edit refused');$scripts[$r['scriptId']]=$bad;
$values[101]=null;$raw=v4captureRun($base,$r['categoryId']);yes($raw['status']==='recorded'&&$raw['accountingQuality']==='unavailable','missing measurement still records an explicit gap');$values[101]=1000;
v4captureStop($base,$r['categoryId']);yes($timers[$r['scriptId']]===0,'stop affects capture timer only');yes(file_exists($r['firstCapture']['journal']),'stop retains original journal');
ob_start();$resume=v4captureInstall($base);ob_end_clean();yes($resume['status']==='capture_installed'&&$timers[$r['scriptId']]===30,'restart by installer retains data');
$objects[$r['categoryId']]['ObjectInfo']='unrelated';ob_start();$err=v4captureInstall($base);ob_end_clean();yes($err['status']==='needs_review','foreign category identity not taken over');
foreach(['runtime.php','install.php']as$f){$s=file_get_contents($repo.'/examples/V4DataCapture/'.$f);yes(!preg_match('/\b(?:RequestAction|IPS_RequestAction|IPS_SetProperty|IPS_ApplyChanges|MC_ReloadModule|AC_SetLoggingStatus|curl_exec|shell_exec)\s*\(/',$s),'no control/network/module reload in '.$f);}
}finally{foreach(new RecursiveIteratorIterator(new RecursiveDirectoryIterator($base,FilesystemIterator::SKIP_DOTS),RecursiveIteratorIterator::CHILD_FIRST)as$p){$p->isDir()&&!$p->isLink()?rmdir($p->getPathname()):unlink($p->getPathname());}rmdir($base);}
echo "TOTAL $count native-mock checks passed; no Symcon kernel calls.\n";
+59
View File
@@ -0,0 +1,59 @@
<?php
// Offline only, no actual Symcon calls.
declare(strict_types=1);
if (function_exists('IPS_GetKernelVersion')) throw new RuntimeException('Offline test only');
require_once __DIR__.'/../../libs/NetzfahrplanV4Rueckmeldung.php';
use Belevo\EnelixEMS\NetzfahrplanV4Rueckmeldung as F;
$count=0;
function check($b,$s){global $count;if(!$b)throw new RuntimeException('FAIL '.$s);$count++;echo "PASS $s\n";}
function fails($f,$s){try{$f();}catch(Throwable $e){check(true,$s);return;}throw new RuntimeException('FAIL no rejection '.$s);}
function fx($mode='physical_sum') {
$now=1791032400;$c=['version'=>1,'installationId'=>'e3a08f9e-af12-4695-99bd-8b51c0520021','assetId'=>'ev','managerId'=>17004,'batteryInstanceId'=>44234,
'mode'=>$mode,'allowEstimatedForTrial'=>false,'maxSkewSeconds'=>30,'maxAgeSeconds'=>60,'idleToleranceW'=>50.0,'trackingToleranceW'=>200.0,'sources'=>[]];
$roles=['grid','physical','physical'];if($mode==='virtual_split')$roles=array_merge($roles,['ev_request','sdl_request','gateway_active']);$values=[3000.0,-2000.0,-1000.0,-3000.0,0.0,true];$r=[];
foreach($roles as$i=>$role){$id=100+$i;$c['sources'][]=['key'=>'s'.$id,'role'=>$role,'variableId'=>$id,'parentId'=>200,'ident'=>'q'.$id,'factorToW'=>1];$r[$id]=['value'=>$values[$i],'updated'=>$now,'changed'=>$now-60,'parentID'=>200,'ident'=>'q'.$id,'confirmedAt'=>$now];}
return [$c,$r,$now];
}
function readit($c,$r,$n){return F::read($c,fn($id)=>$r[$id],$n);}
[$c,$r,$n]=fx();$f=readit($c,$r,$n);
check($f['batteryW']===-3000.0&&$f['gridW']===3000.0,'physical total and grid signs retained');
check($f['usableForTrial']&&!$f['estimated']&&!$f['canDispatch'],'usable feedback alone never grants dispatch');
F::requireTrial($f,$c,$n);check(true,'matching trial binding accepted');
check(F::fingerprint($c)===F::fingerprint(json_decode(json_encode($c),true)),'configuration hash stable over numeric property roundtrip');
$d=$c;$d['sources']=array_reverse($d['sources']);check(F::fingerprint($c)===F::fingerprint($d),'source list order canonical');
$d['trackingToleranceW']=201;check(F::fingerprint($c)!==F::fingerprint($d),'changed tolerance invalidates session hash');
foreach([true,'1',NAN,INF,null]as$bad){$x=$r;$x[101]['value']=$bad;fails(fn()=>readit($c,$x,$n),'invalid physical number '.gettype($bad));}
$x=$r;$x[101]['updated']=$n-61;$x[101]['changed']=$n-90;$f=readit($c,$x,$n);check($f['sourceVariableOldestAt']===$n-61,'confirmed read does not manufacture VariableUpdated');
$x=$r;unset($x[101]['confirmedAt']);fails(fn()=>readit($c,$x,$n),'physical source without confirmed device read rejected');
$x=$r;$x[101]['confirmedAt']=$n-31;fails(fn()=>readit($c,$x,$n),'incoherent confirmed device timestamps rejected');
$x=$r;$x[101]['updated']=$n+1;fails(fn()=>readit($c,$x,$n),'future original sample rejected');
$x=$r;$x[101]['parentID']=201;fails(fn()=>readit($c,$x,$n),'reused variable wrong parent rejected');
$x=$r;$x[101]['ident']='other';fails(fn()=>readit($c,$x,$n),'reused variable wrong ident rejected');
$d=$c;$d['sources'][1]['variableId']=$d['sources'][0]['variableId'];fails(fn()=>F::configuration($d),'duplicate physical source rejected');
$d=$c;$d['maxAgeSeconds']=300;fails(fn()=>F::configuration($d),'historical freshness not accepted for live control');
$calls=[];fails(function()use($c,$r,$n,&$calls){F::read($c,function($id)use($r,&$calls){$calls[$id]=($calls[$id]??0)+1;$x=$r[$id];if($calls[$id]>1)$x['value']+=1;return$x;},$n);},'same-second change during double read rejected');
$d=$c;$d['sources'][1]['factorToW']=-1;check(readit($d,$r,$n)['batteryW']===1000.0,'vendor sign conversion once');
$f=readit($c,$r,$n);fails(fn()=>F::requireTrial($f,$c,$n+3),'old validated snapshot not reused');
$d=$c;$d['managerId']=20000;fails(fn()=>F::requireTrial($f,$d,$n),'different manager cannot use feedback');
$d=$c;$d['feedbackConfigHash']=str_repeat('a',64);fails(fn()=>F::requireTrial($f,$d,$n),'changed mapping aborts trial');
[$c,$r,$n]=fx('virtual_split');$r[103]['updated']=$r[104]['updated']=$r[105]['updated']=$n-86400;$r[103]['changed']=$r[104]['changed']=$r[105]['changed']=$n-172800;
$r[101]['value']=$r[102]['value']=$r[103]['value']=$r[104]['value']=0.0;$f=readit($c,$r,$n);
check($f['batteryW']===0.0&&$f['estimated'],'unchanged idle commands need no artificial heartbeat');
check(!$f['usableForTrial'],'virtual estimate requires explicit acceptance');
$c['allowEstimatedForTrial']=true;$f=readit($c,$r,$n);check($f['usableForTrial']&&!$f['gatewayWatchdogVerified'],'idle acceptance is not gateway watchdog proof');
$r[101]['value']=20.0;$f=readit($c,$r,$n);check($f['batteryW']===20.0,'idle residual not clipped to zero');
$r[101]['value']=60.0;fails(fn()=>readit($c,$r,$n),'unexplained idle power refused');
$r[101]['value']=-2000.0;$r[102]['value']=-1000.0;$r[103]['value']=2000.0;$r[103]['updated']=$n;$r[103]['changed']=$n;
$f=readit($c,$r,$n);check($f['batteryW']===-3000.0,'single EV feedback follows physics not desired sign or held filter');
$r[104]['value']=1000.0;$r[104]['updated']=$n;$r[104]['changed']=$n;$r[101]['confirmedAt']=$n-1;
fails(fn()=>readit($c,$r,$n),'SDL transition after physical confirmation cannot be guessed');
[$c,$r,$n]=fx('virtual_split');$c['allowEstimatedForTrial']=true;$r[103]['value']=3000.0;$r[104]['value']=1000.0;$r[101]['value']=1900.0;$r[102]['value']=2000.0;$f=readit($c,$r,$n);
check($f['batteryW']===2925.0&&$f['externalModelW']===975.0,'small tracking error allocated but explicitly estimated');
check(!$f['separatelyMeasured']&&$f['heldFilterUsed']===false,'partition never labelled separately measured');
$r[101]['value']=1000.0;fails(fn()=>readit($c,$r,$n),'large tracking error prevents stale virtual correction');
$r[103]['value']=42000.0;$r[104]['value']=-40000.0;$r[101]['value']=0.0;$r[102]['value']=2000.0;$f=readit($c,$r,$n);
check(!$f['usableForTrial']&&in_array('opposing_virtual_flows_not_identifiable',$f['reasons'],true),'counterflow cannot be independently attributed');
$r[105]['value']=false;fails(fn()=>readit($c,$r,$n),'disabled gateway blocks feedback');
$src=file_get_contents(__DIR__.'/../../libs/NetzfahrplanV4Rueckmeldung.php');
check(!preg_match('/\b(?:SetValue|IPS_RequestAction|curl_exec|IPS_SetProperty|AC_SetLoggingStatus)\s*\(/',$src),'pure feedback has no control side effects');
echo "TOTAL $count physical-feedback checks passed. Synthetic source readings only.\n";
+63
View File
@@ -0,0 +1,63 @@
<?php
// Offline only, no IP-Symcon or real device access.
declare(strict_types=1);
if (function_exists('IPS_GetKernelVersion')) throw new RuntimeException('Offline test only');
require_once __DIR__.'/../../libs/NetzfahrplanV4Rueckmeldung.php';
require_once __DIR__.'/../../libs/NetzfahrplanV4Geraeteabruf.php';
use Belevo\EnelixEMS\NetzfahrplanV4Geraeteabruf as D;
$count=0;
function dcheck($ok,$name){global $count;if(!$ok)throw new RuntimeException('FAIL '.$name);$count++;echo "PASS $name\n";}
function dfails($fn,$name){try{$fn();}catch(Throwable $e){dcheck(true,$name);return;}throw new RuntimeException('FAIL no rejection '.$name);}
function dfixture(){
$now=1791032400;
$config=['version'=>1,'installationId'=>'e3a08f9e-af12-4695-99bd-8b51c0520021','assetId'=>'ev','managerId'=>17004,'batteryInstanceId'=>44234,
'mode'=>'physical_sum','allowEstimatedForTrial'=>false,'maxSkewSeconds'=>30,'maxAgeSeconds'=>60,'idleToleranceW'=>50.0,'trackingToleranceW'=>200.0,
'sources'=>[
['key'=>'grid','role'=>'grid','variableId'=>10,'parentId'=>20,'ident'=>'grid','factorToW'=>1.0],
['key'=>'battery_a','role'=>'physical','variableId'=>11,'parentId'=>21,'ident'=>'a','factorToW'=>1.0],
['key'=>'battery_b','role'=>'physical','variableId'=>12,'parentId'=>21,'ident'=>'b','factorToW'=>1.0],
]];
$records=[
10=>['value'=>3000.0,'updated'=>$now-100,'changed'=>$now-200,'parentID'=>20,'ident'=>'grid'],
11=>['value'=>-1000.0,'updated'=>$now-100,'changed'=>$now-200,'parentID'=>21,'ident'=>'a'],
12=>['value'=>-2000.0,'updated'=>$now-100,'changed'=>$now-200,'parentID'=>21,'ident'=>'b'],
];
return [$config,$records,$now];
}
[$c,$r,$n]=dfixture();$calls=[];$times=[$n-1,$n-1,$n];
$f=D::snapshot($c,fn($id)=>$id===20?D::MBUS_DEVICE:D::MODBUS_DEVICE,
function($id,$module)use(&$calls){$calls[]=$id;return true;},
fn($id)=>$r[$id],function()use(&$times){return array_shift($times);});
sort($calls);dcheck($calls===[20,21],'one refresh per physical parent');
dcheck($f['deviceReadConfirmed']===true&&$f['sourceOldestAt']===$n-1,'successful read confirmation is freshness basis');
dcheck($f['sourceVariableOldestAt']===$n-100,'original VariableUpdated retained separately');
dcheck($f['batteryW']===-3000.0&&$f['usableForTrial'],'unchanged values accepted after confirmed device response');
[$c,$r,$n]=dfixture();$times=[$n,$n,$n];$attempts=0;$pauses=[];
$f=D::snapshot($c,fn($id)=>D::MODBUS_DEVICE,
function()use(&$attempts){$attempts++;return $attempts>=2;},fn($id)=>$r[$id],
function()use(&$times){return array_shift($times);},function($us)use(&$pauses){$pauses[]=$us;});
dcheck($attempts===3&&$pauses===[200000],'transient busy read is retried once without relaxing confirmation');
[$c,$r,$n]=dfixture();$times=[$n,$n,$n];
dfails(fn()=>D::snapshot($c,fn($id)=>D::MODBUS_DEVICE,fn()=>false,fn($id)=>$r[$id],function()use(&$times){return array_shift($times);}),
'failed device read rejected');
[$c,$r,$n]=dfixture();$times=[$n,$n,$n];$reads=0;
$f=D::snapshot($c,fn($id)=>D::MODBUS_DEVICE,fn()=>true,
function($id)use(&$r,&$reads){$reads++;$x=$r[$id];if($reads>3&&$id===10){$x['value']++;$x['updated']++;$x['changed']++;}return $x;},
function()use(&$times){return array_shift($times);});
dcheck($f['gridW']===3000.0,'newer process value after confirmed response does not invalidate confirmed snapshot');
[$c,$r,$n]=dfixture();$times=[$n,$n,$n];$reads=0;
dfails(fn()=>D::snapshot($c,fn($id)=>D::MODBUS_DEVICE,fn()=>true,
function($id)use(&$r,&$reads){$reads++;$x=$r[$id];if($reads>3&&$id===10)$x['parentID']=999;return $x;},
function()use(&$times){return array_shift($times);}), 'source identity change after confirmed response rejected');
[$c,$r,$n]=dfixture();$times=[$n,$n,$n];
dfails(fn()=>D::snapshot($c,fn($id)=>'{00000000-0000-0000-0000-000000000000}',fn()=>true,fn($id)=>$r[$id],
function()use(&$times){return array_shift($times);}), 'unsupported device module rejected');
echo "TOTAL $count confirmed-device-read checks passed. Synthetic calls only.\n";
+23
View File
@@ -0,0 +1,23 @@
<?php
// Exercise the EXACT serialized fallback wrapper from Manager/module.php in an isolated harness.
if (function_exists('IPS_GetKernelVersion')) throw new RuntimeException('Offline only');
$source=file_get_contents(__DIR__.'/../../Manager/module.php');
$a=strpos($source,' private function regeln(bool $erneuern): void');$b=strpos($source,' private function regelnIntern(bool $erneuern): void',$a);
if($a===false||$b===false)throw new RuntimeException('Manager wrapper source changed');
$method=substr($source,$a,$b-$a);$calls=[];$locked=false;
function IPS_SemaphoreEnter($k,$t){global$locked;if($locked)return false;$locked=true;return true;}
function IPS_SemaphoreLeave($k){global$locked;$locked=false;}
function IPS_GetInstance($id){return ['InstanceStatus'=>$GLOBALS['instance']->status];}
eval('class FallbackHarness { const STATUS_AKTIV=102; public $InstanceID=17004,$trial=true,$active=true,$status=102,$abortFirst=true,$throwFirst=false,$passes=[],$attributes=[],$stops=[];'.
'public function run(){ $this->regeln(false); } private function v4ManagerTestSession(){return $this->trial?[1]:[];} private function GetValue($k){return $this->active;}'.
'private function WriteAttributeString($k,$v){$this->attributes[$k]=$v;} private function v4ManagerTestAbbrechen($s){$this->stops[]=$s;$this->trial=false;}'.
'private function regelnIntern(bool $r):void { $this->passes[]=$r;if(count($this->passes)==1){if($this->throwFirst)throw new RuntimeException("synthetic_failure");if($this->abortFirst)$this->trial=false;}}'. $method.'}');
$count=0;function ck($v,$s){global$count;if(!$v)throw new RuntimeException('FAIL '.$s);$count++;echo "PASS $s\n";}
$instance=new FallbackHarness();$instance->run();ck($instance->passes===[false,true],'trial abort performs exactly one fresh ordinary recomputation');
ck($instance->attributes['LetzteSollwerte']==='{}','pre-trial cached target is discarded before fallback');ck(!$locked,'control semaphore released after fallback');
$instance=new FallbackHarness();$instance->trial=false;$instance->run();ck($instance->passes===[false],'ordinary control path does not gain extra passes');
$instance=new FallbackHarness();$instance->abortFirst=false;$instance->run();ck($instance->passes===[false],'valid trial not overridden by ordinary pass');
$instance=new FallbackHarness();$instance->status=201;$instance->run();ck($instance->passes===[false],'invalid measurement status prevents forced ordinary recomputation');
$instance=new FallbackHarness();$instance->throwFirst=true;try{$instance->run();}catch(RuntimeException $e){}
ck(!$locked&&!$instance->trial&&count($instance->stops)===1,'exception revokes trial and releases semaphore without endless retries');
echo "TOTAL $count actual manager fallback-wrapper checks passed. No real IPS runtime.\n";
+23
View File
@@ -0,0 +1,23 @@
<?php
// Exact Battery message builder with no optional visible diagnostic variables.
if(function_exists('IPS_GetKernelVersion'))throw new RuntimeException('Offline only');
class IPSModule {
public int $InstanceID=44234;public array $buffers=['V4FeedbackEstimated'=>'1'];public bool $valid=true;
protected function GetBuffer($k){return $this->buffers[$k]??'';}
protected function GetValue($k){throw new RuntimeException('No diagnostic variables exist: '.$k);}
protected function ReadAttributeBoolean($k){return $k==='ZustandLadezustandGueltig'?$this->valid:false;}
protected function ReadAttributeInteger($k){return 0;}
protected function ReadAttributeFloat($k){return 0.0;}
protected function ReadAttributeString($k){return $k==='Leistungsangebot'?'[0]':($k==='Betriebsart'?'Auto':'');}
protected function ReadPropertyInteger($k){return 1;}
}
require __DIR__.'/../../Batterie/module.php';
use Belevo\EnelixEMS\Nachrichtenvertrag as N;
$b=new Batterie();$read=new ReflectionMethod(Batterie::class,'baueVerbraucherdaten');$count=0;
function ck($v,$s){global$count;if(!$v)throw new RuntimeException('FAIL '.$s);$count++;echo "PASS $s\n";}
$d=$read->invoke($b,17004);ck($d['Leistungsquelle']===N::LEISTUNGSQUELLE_BERECHNET,'partition source quality independent of hidden diagnostic variables');
$b->buffers['V4FeedbackEstimated']='0';$d=$read->invoke($b,17004);ck($d['Leistungsquelle']===N::LEISTUNGSQUELLE_GEMESSEN,'ordinary physical source marked measured');
$b->valid=false;$d=$read->invoke($b,17004);ck($d['Leistungsquelle']===N::LEISTUNGSQUELLE_NICHT_VORHANDEN&&$d['Istleistung_W']===null,'invalid sample never published as measured');
$b->valid=true;$b->buffers['V4FeedbackEstimated']='1';$b->buffers['V4BatteryTrialSession']='{}';$d=$read->invoke($b,17004);ck($d['Leistungsquelle']===N::LEISTUNGSQUELLE_BERECHNET,'last estimate not relabelled measured immediately after trial ends');
$source=file_get_contents(__DIR__.'/../../Batterie/module.php');ck(str_contains($source,"SetBuffer('V4FeedbackEstimated', (\$messwerte['V4FeedbackEstimated'] ?? false) ? '1' : '0')"),'fresh measurement updates internal source quality');
echo "TOTAL $count actual message-builder checks passed. No real variables or writes.\n";
+46
View File
@@ -0,0 +1,46 @@
<?php
namespace Belevo\EnelixEMS {
if (\function_exists('IPS_GetKernelVersion')) throw new \RuntimeException('CLI test only');
require_once __DIR__.'/../../libs/ManagerNetzfahrplanV4EmpfangTrait.php';
class Lizenzpruefung {const NETZFAHRPLAN='synthetic';}
function time(){return $GLOBALS['now'];}
function IPS_InstanceExists($id){return $id===44234;}
function IPS_GetInstance($id){return ['ModuleInfo'=>['ModuleID'=>'{437FB683-517F-4FEC-8CCB-FE6B0A62B69E}']];}
function IPS_GetProperty($id,$k){return ['IstleistungVariableID'=>1002,'LadezustandVariableID'=>1001,'NetzfahrplanV4RueckmeldungKonfiguration'=>$GLOBALS['configured']?'configured':'{}'][$k];}
function IPS_VariableExists($id){return $id===1002;}
function IPS_GetVariable($id){return ['VariableUpdated'=>$GLOBALS['now']];}
function GetValue($id){return -9000.;} // deliberately held/incorrect legacy virtual display
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'=>'{}'];}
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 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']);}
require_once __DIR__.'/../V4Receiver/fixture.php';
use Belevo\EnelixEMS\FeedbackReceiver;
[$response,$id,$op,$now]=receiverFixture();$configured=true;$count=0;$feedbackDelay=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);
ck($p['status']==='preview_only'&&$p['actualBatteryW']==0,'real receiver uses corrected physical feedback rather than held -9kW');
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');
$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');
$configured=false;$p=json_decode($m->GetNetzfahrplanV4Vorschau(),true);
ck($p['status']==='preview_only'&&$p['feedbackMeaning']==='legacy_display_only_not_for_trial','unconfigured old preview stays compatible but explicitly not for control');
ck($p['canDispatch']===false,'legacy display cannot gain actuation permission');
echo "TOTAL $count actual receiver checks passed; synthetic read-only controller API.\n";
}
+30
View File
@@ -0,0 +1,30 @@
<?php
function receiverFixture(): array
{
$now = strtotime('2026-10-02T12:00:00Z');
$id = 'e3a08f9e-af12-4695-99bd-8b51c0520021';
$operation = ['eventId' => 'op-original', 'observedAt' => gmdate('c', $now), 'gridW' => -3000.0,
'batteries' => [['id' => 'b', 'capacityKwh' => 20.0, 'socPercent' => 50.0, 'minSocPercent' => 15.0,
'maxSocPercent' => 90.0, 'physicalMinSocPercent' => 3.0, 'rearmSocPercent' => 17.0,
'gridCharging' => true, 'dischargeBlocked' => false, 'maxChargeW' => 39000.0, 'maxDischargeW' => 39000.0,
'measuredAt' => gmdate('c', $now)]],
'limits' => ['importW' => 25000.0, 'exportW' => 25000.0, 'managerMonthLimitsW' => []]];
$points = [];
for ($i = 0; $i < 6; $i++) $points[] = ['time' => gmdate('c', $now + $i*300), 'validUntil' => gmdate('c', $now + ($i+1)*300),
'baselineGridW' => -3000.0, 'gridTargetW' => 0.0, 'batteryTargetW' => 3000.0, 'pvCurtailmentW' => 0.0,
'assetTargetsW' => ['b' => 3000.0], 'socEndPercent' => ['b' => 50.0 + $i], 'intent' => 'pvCharge',
'importLimitW' => 25000.0, 'exportLimitW' => 25000.0];
$plan = ['schemaVersion' => 2, 'planId' => '10000000-0000-4000-8000-000000000001', 'installationId' => $id,
'configRevision' => 1, 'sourceFamily' => '3', 'runMode' => 'shadow', 'liveEnabled' => false, 'executable' => true,
'status' => 'optimal', 'generatedAt' => gmdate('c', $now), 'validFrom' => gmdate('c', $now),
'validUntil' => gmdate('c', $now + 1800), 'pricesKnownUntil' => gmdate('c', $now + 1800),
'controlContext' => \Belevo\EnelixEMS\NetzfahrplanV4Planpruefung::kontext($operation),
'inputRefs' => ['operation' => 'op-original', 'forecast' => 'fc-original', 'tariffs' => 'tar-original'],
'points' => $points, 'warnings' => ['Estimated peak; shadow only']];
$response = ['receiverProtocolVersion' => 1, 'installationId' => $id, 'checkedAt' => gmdate('c', $now),
'liveEnabled' => false, 'fresh' => true, 'pending' => null,
'settings' => ['revision' => 1, 'family' => '3', 'runMode' => 'shadow', 'roundtripEfficiency' => .9],
'families' => [['key' => '3'], ['key' => '13'], ['key' => '23']], 'plan' => $plan,
'lastRun' => ['status' => 'optimal', 'detail' => ['planId' => $plan['planId']]]];
return [$response, $id, $operation, $now];
}
+73
View File
@@ -0,0 +1,73 @@
<?php
require_once __DIR__ . '/../../libs/NetzfahrplanV4Planpruefung.php';
require_once __DIR__ . '/fixture.php';
use Belevo\EnelixEMS\NetzfahrplanV4Planpruefung as P;
$count = 0;
function check($ok, string $label): void { global $count; if (!$ok) throw new RuntimeException('FAIL ' . $label); $count++;echo 'PASS ' . $label . "\n"; }
function invalid(string $name, callable $change): void {
[$r,$id,$op,$now] = receiverFixture();$receipt=$now;
$change($r,$op,$now,$receipt);
try { P::pruefen($r,$id,$op,$now,$receipt); } catch (Throwable $e) { check(true,$name);return; }
check(false,$name);
}
[$r,$id,$op,$now]=receiverFixture();
$x=P::pruefen($r,$id,$op,$now,$now);$v=P::vorschau($x,$op,['b'=>0.0],$now);
check($v['previewBatteryW']===3000.0 && $v['canDispatch']===false && $v['liveEnabled']===false,'zero grid uses actual surplus, never permission');
invalid('wrong installation',fn(&$r)=>$r['installationId']='00000000-0000-4000-8000-000000000001');
invalid('foreign plan',fn(&$r)=>$r['plan']['installationId']='00000000-0000-4000-8000-000000000001');
invalid('old protocol',function(&$r){unset($r['receiverProtocolVersion']);});
invalid('revision mismatch',fn(&$r)=>$r['plan']['configRevision']=2);
invalid('boolean revision',fn(&$r)=>$r['plan']['configRevision']=true);
invalid('live response',fn(&$r)=>$r['liveEnabled']=true);
invalid('live plan',fn(&$r)=>$r['plan']['runMode']='live');
invalid('fake executable',fn(&$r)=>$r['plan']['executable']='true');
invalid('stale server flag',fn(&$r)=>$r['fresh']=false);
invalid('pending computation',fn(&$r)=>$r['pending']=['reason'=>'new price']);
invalid('last run different plan',fn(&$r)=>$r['lastRun']['detail']['planId']='wrong');
invalid('last run invalid',fn(&$r)=>$r['lastRun']['status']='invalid_inputs');
invalid('family mismatch',fn(&$r)=>$r['settings']['family']='23');
invalid('unknown family',fn(&$r)=>$r['plan']['sourceFamily']='unregistered');
invalid('receipt stale',function(&$r,&$op,&$now,&$receipt){$receipt-=91;});
invalid('future receipt',function(&$r,&$op,&$now,&$receipt){$receipt++;});
invalid('stale server response',function(&$r)use($now){$r['checkedAt']=gmdate('c',$now-91);});
invalid('future plan',function(&$r)use($now){$r['plan']['generatedAt']=gmdate('c',$now+10);});
invalid('stale plan',function(&$r)use($now){$r['plan']['generatedAt']=gmdate('c',$now-901);});
invalid('price horizon too short',function(&$r)use($now){$r['plan']['pricesKnownUntil']=gmdate('c',$now+300);});
invalid('stale local operation',function(&$r,&$op)use($now){$op['observedAt']=gmdate('c',$now-61);});
invalid('changed local reserve',fn(&$r,&$op)=>$op['batteries'][0]['minSocPercent']=30.0);
invalid('changed local grid limit',fn(&$r,&$op)=>$op['limits']['importW']=2000.0);
invalid('typed context required',fn(&$r)=>$r['plan']['controlContext']['batteries']['b']['capacityKwh']='20');
invalid('input provenance missing',function(&$r){unset($r['plan']['inputRefs']);});
invalid('unknown assets',function(&$r){$r['plan']['points'][0]['assetTargetsW']=['c'=>3000];});
invalid('overlap',function(&$r){$r['plan']['points'][1]['time']=$r['plan']['points'][0]['time'];});
invalid('gap',function(&$r)use($now){$r['plan']['points'][1]['time']=gmdate('c',$now+310);});
invalid('bool target',fn(&$r)=>$r['plan']['points'][0]['gridTargetW']=false);
invalid('string target',fn(&$r)=>$r['plan']['points'][0]['gridTargetW']='0');
invalid('NaN target',fn(&$r)=>$r['plan']['points'][0]['batteryTargetW']=NAN);
invalid('broken balance',fn(&$r)=>$r['plan']['points'][0]['baselineGridW']=10000.0);
invalid('unknown intent',fn(&$r)=>$r['plan']['points'][0]['intent']='discharge_all');
invalid('no timezone',fn(&$r)=>$r['plan']['points'][0]['time']='2026-10-02T12:00:00');
invalid('bad calendar date',fn(&$r)=>$r['plan']['points'][0]['time']='2026-02-30T12:00:00Z');
invalid('missing step',fn(&$r)=>$r['plan']['points']=[]);
invalid('negative curtailment',fn(&$r)=>$r['plan']['points'][0]['pvCurtailmentW']=-100.0);
[$r,$id,$op,$now]=receiverFixture();$r['checkedAt']=$op['observedAt']=gmdate('c',$now+300);$op['batteries'][0]['measuredAt']=$op['observedAt'];
$x=P::pruefen($r,$id,$op,$now+300,$now+300);
check($x['point']['time']===gmdate('c',$now+300),'half-open boundary picks current, not interpolated');
[$r,$id,$op,$now]=receiverFixture();$r['plan']['sourceFamily']=$r['settings']['family']='future-family';$r['families'][]=['key'=>'future-family'];
check(P::pruefen($r,$id,$op,$now,$now)['plan']['sourceFamily']==='future-family','registry is extensible');
[$r,$id,$op,$now]=receiverFixture();$op['gridW']=3000.0;$op['batteries'][0]['dischargeBlocked']=true;
$x=P::pruefen($r,$id,$op,$now,$now);$v=P::vorschau($x,$op,['b'=>0],$now);
check($v['previewBatteryW']===0.0,'local hysteresis prevents preview discharge');
[$r,$id,$op,$now]=receiverFixture();$op['gridW']=3000.0;$op['batteries'][0]['socPercent']=15.001;
$x=P::pruefen($r,$id,$op,$now,$now);$v=P::vorschau($x,$op,['b'=>0],$now);
check($v['previewBatteryW']>-3 && $v['previewBatteryW']<0,'remaining-step energy cannot cross reserve');
[$r,$id,$op,$now]=receiverFixture();$op['batteries'][0]['socPercent']=90;
$x=P::pruefen($r,$id,$op,$now,$now);$v=P::vorschau($x,$op,['b'=>0],$now);check($v['previewBatteryW']===0.0,'full battery cannot charge');
[$r,$id,$op,$now]=receiverFixture();$op['batteries'][0]['maxChargeW']=0;$op['limits']['exportW']=0.;$r['plan']['controlContext']=P::kontext($op);
$x=P::pruefen($r,$id,$op,$now,$now);$v=P::vorschau($x,$op,['b'=>0],$now);check($v['unmetGridLimitW']===3000.0 && !$v['canDispatch'],'zero export limit reports unmet constraint');
[$r,$id,$op,$now]=receiverFixture();$x=P::pruefen($r,$id,$op,$now,$now);$op['gridW']=2000;$op['batteries'][0]['gridCharging']=false;
$x['point']['gridTargetW']=8000.;$x['point']['intent']='gridCharge';$v=P::vorschau($x,$op,['b'=>0],$now);check($v['previewBatteryW']===0.,'grid charging opt-in enforced');
$op['batteries'][0]['gridCharging']=true;$x['point']['intent']='hold';$v=P::vorschau($x,$op,['b'=>0],$now);check($v['previewBatteryW']===0.,'nonzero hold does not accidentally charge');
$x['point']['gridTargetW']=0.;$x['point']['intent']='discharge';$op['gridW']=0.;$v=P::vorschau($x,$op,['b'=>-2000],$now);check($v['previewBatteryW']===-2000.,'uses actual battery feedback, not incremental zero');
try { P::dekodieren(str_repeat('x',P::MAX_RESPONSE_BYTES+1));check(false,'body limit'); } catch (InvalidArgumentException $e) {check(true,'body limit');}
echo "Total: $count checks. Synthetic data only.\n";
+140
View File
@@ -0,0 +1,140 @@
<?php
namespace Belevo\EnelixEMS {
require_once __DIR__ . '/../../libs/ManagerNetzfahrplanV4EmpfangTrait.php';
class Lizenzpruefung { public const NETZFAHRPLAN = 'grid_schedule'; }
foreach (['CURLOPT_CUSTOMREQUEST','CURLOPT_FOLLOWLOCATION','CURLOPT_CONNECTTIMEOUT','CURLOPT_TIMEOUT',
'CURLOPT_SSL_VERIFYPEER','CURLOPT_SSL_VERIFYHOST','CURLOPT_PROTOCOLS','CURLOPT_HTTPHEADER',
'CURLOPT_WRITEFUNCTION','CURLOPT_POSTFIELDS','CURLINFO_HTTP_CODE','CURLPROTO_HTTPS'] as $i=>$name) {
if (!defined($name)) define($name,50000+$i);
}
function time() { return $GLOBALS['clock']; }
function IPS_SemaphoreEnter($name,$wait) { if ($GLOBALS['lock']) return false;$GLOBALS['lock']=true;return true; }
function IPS_SemaphoreLeave($name) { $GLOBALS['lock']=false; }
function IPS_VariableExists($id) {return $id===100;}
function IPS_GetVariable($id) {return ['VariableUpdated'=>$GLOBALS['clock']];}
function GetValue($id) {return $GLOBALS['actual'];}
function curl_init($url) { return (object)['url'=>$url,'options'=>[],'code'=>0]; }
function curl_setopt_array($h,$options) { $h->options=$options;return true; }
function curl_getinfo($h,$key) { return $h->code; }
function curl_close($h) {}
function curl_exec($h) {
$GLOBALS['requests'][]=['url'=>$h->url,'options'=>$h->options];
$ack=str_ends_with($h->url,'/ack');
$h->code=$ack?$GLOBALS['ackCode']:$GLOBALS['getCode'];
$body=$ack?['status'=>'recorded']:$GLOBALS['response'];
$json=json_encode($body,JSON_THROW_ON_ERROR);
$callback=$h->options[CURLOPT_WRITEFUNCTION];
return $callback($h,$json)===strlen($json);
}
final class ReceiverHarness {
use ManagerNetzfahrplanV4EmpfangTrait;
public int $InstanceID=17004;
public array $props=[],$attrs=[],$vars=[],$timers=[];
public bool $licensed=true;
public array $session=[];
public array $op=[];
public function __construct($id,$op) {
$this->op=$op;
$this->props=['NetzfahrplanV4EmpfangAktiv'=>false,'NetzfahrplanV4SchattenAktiv'=>true,
'NetzfahrplanAktiv'=>false,'MesswertMaxAlter'=>60,
'AnlagenBatterien'=>json_encode([['ID'=>'b','LeistungVariableID'=>100,'Leistungsfaktor'=>1]])];
$this->attrs=['LizenzInstallationID'=>$id,'PrognoseInstallationsToken'=>str_repeat('x',43)];
$this->registriereNetzfahrplanV4Empfang();
}
public function receive() {$this->empfangeNetzfahrplanV4();}
public function update() {$this->aktualisiereNetzfahrplanV4Vorschau();}
public function stop() {$this->stoppeNetzfahrplanV4Vorschau();}
public function configure() {$this->konfiguriereNetzfahrplanV4Vorschau();}
public function GetNetzfahrplanV4Diagnose() { return json_encode(['operation'=>$this->op]); }
private function aktiveVerbraucherIDs() {return [];}
private function v4ManagerTestSession() {return $this->session;}
private function berechtigungLizenziert($key) {return $this->licensed;}
private function ReadPropertyBoolean($key) {return (bool)$this->props[$key];}
private function ReadPropertyString($key) {return $this->props[$key];}
private function ReadPropertyInteger($key) {return (int)$this->props[$key];}
private function ReadAttributeString($key) {return $this->attrs[$key]??'';}
private function ReadAttributeInteger($key) {return (int)($this->attrs[$key]??0);}
private function WriteAttributeString($key,$val) {
if (!str_starts_with($key,'NetzfahrplanV4')) throw new \RuntimeException('Unexpected external attribute');
$this->attrs[$key]=$val;
}
private function WriteAttributeInteger($key,$val) { $this->attrs[$key]=$val; }
private function RegisterPropertyBoolean($key,$default) {$this->props[$key]??=$default;}
private function RegisterAttributeString($key,$default) {$this->attrs[$key]??=$default;}
private function RegisterAttributeInteger($key,$default) {$this->attrs[$key]??=$default;}
private function RegisterVariableString($key,$name,$profile,$pos) {$this->vars[$key]='';}
private function RegisterTimer($key,$interval,$script) {$this->timers[$key]=$interval;}
private function SetTimerInterval($key,$interval) {$this->timers[$key]=$interval;}
private function SetValue($key,$value) {
if (!in_array($key,['NetzfahrplanV4Vorschau','NetzfahrplanV4VorschauJSON'],true)) throw new \RuntimeException('Actuator/foreign variable write');
$this->vars[$key]=$value;
}
}
}
namespace {
require_once __DIR__.'/fixture.php';
use Belevo\EnelixEMS\ReceiverHarness;
$count=0;
function check($ok,$name) {global $count;if (!$ok) throw new RuntimeException('FAIL '.$name);$count++;echo 'PASS '.$name."\n";}
function fixture() {
[$r,$id,$op,$now]=receiverFixture();
$GLOBALS['response']=$r;$GLOBALS['clock']=$now;$GLOBALS['actual']=0.;$GLOBALS['requests']=[];
$GLOBALS['lock']=false;$GLOBALS['getCode']=200;$GLOBALS['ackCode']=200;
return new ReceiverHarness($id,$op);
}
function advance($h,$seconds) {
$GLOBALS['clock']+=$seconds;$GLOBALS['response']['checkedAt']=gmdate('c',$GLOBALS['clock']);
$h->op['observedAt']=gmdate('c',$GLOBALS['clock']);$h->op['batteries'][0]['measuredAt']=$h->op['observedAt'];
}
$h=fixture();$h->receive();check(count($requests)===0,'default disabled no network');
$h->props['NetzfahrplanV4EmpfangAktiv']=true;$h->configure();check($h->timers['NetzfahrplanV4VorschauAktualisieren']===10000,'watchdog without network');
$h->receive();$v=json_decode($h->GetNetzfahrplanV4Vorschau(),true);
check($v['status']==='preview_only' && $v['previewBatteryW']===3000 && $v['canDispatch']===false,'read and show only');
check(count($requests)===2 && str_ends_with($requests[1]['url'],'/ack'),'one read and one acknowledgement');
$body=json_decode($requests[1]['options'][CURLOPT_POSTFIELDS],true);check($body['status']==='shadow_seen' && $body['step']===$response['plan']['points'][0]['time'],'ack is shadow_seen, never applied');
$h->receive();check(count($requests)===2,'repeated click throttled');
advance($h,60);$h->receive();check(count($requests)===3,'same plan step not acknowledged twice');
advance($h,91);$h->update();$v=json_decode($h->vars['NetzfahrplanV4VorschauJSON'],true);
check($v['status']==='unavailable' && $v['previewBatteryW']===null,'expired cache clears numeric targets, not fake zero');
check(!str_contains($h->vars['NetzfahrplanV4Vorschau'],'3000'),'expired HTML clears previous setpoint');
$h=fixture();$h->props['NetzfahrplanV4EmpfangAktiv']=true;$h->receive();advance($h,61);$GLOBALS['getCode']=429;$h->receive();
check($h->attrs['NetzfahrplanV4Empfang']==='{}' && json_decode($h->GetNetzfahrplanV4Vorschau(),true)['previewBatteryW']===null,'429 invalidates cache');
check(!str_contains(json_encode($h->vars),str_repeat('x',43)),'no credentials in diagnosis');
$h=fixture();$h->props['NetzfahrplanV4EmpfangAktiv']=true;$h->props['NetzfahrplanAktiv']=true;$h->receive();check(count($requests)===0,'legacy execution on blocks preview acquisition');
$h=fixture();$h->props['NetzfahrplanV4EmpfangAktiv']=true;$GLOBALS['response']['installationId']='00000000-0000-4000-8000-000000000001';$h->receive();
check(count($requests)===1 && $h->attrs['NetzfahrplanV4Empfang']==='{}','foreign response not acknowledged');
$h=fixture();$h->props['NetzfahrplanV4EmpfangAktiv']=true;$GLOBALS['ackCode']=503;$h->receive();$v=json_decode($h->GetNetzfahrplanV4Vorschau(),true);
check($v['status']==='preview_only' && !$v['reception']['acknowledged'] && isset($v['reception']['acknowledgementError']),'ack failure is visible, no false execution confirmation');
$h=fixture();$h->props['NetzfahrplanV4EmpfangAktiv']=true;$h->receive();$h->op['batteries'][0]['minSocPercent']=30.;$h->update();
check(json_decode($h->vars['NetzfahrplanV4VorschauJSON'],true)['previewBatteryW']===null,'local policy change invalidates cache');
$h=fixture();$h->props['NetzfahrplanV4EmpfangAktiv']=true;$h->receive();$h->stop();check($h->attrs['NetzfahrplanV4Empfang']==='{}' && $h->timers['NetzfahrplanV4VorschauAktualisieren']===0,'apply/restart invalidates cached plan');
$h=fixture();$h->props['NetzfahrplanV4EmpfangAktiv']=true;$h->licensed=false;$h->receive();check(count($requests)===0,'no license no read');
$h=fixture();$h->props['NetzfahrplanV4EmpfangAktiv']=true;$GLOBALS['lock']=true;$h->receive();check(count($requests)===0,'concurrent receiver does not send');
$h=fixture();$h->props['NetzfahrplanV4EmpfangAktiv']=true;$h->receive();$h->props['NetzfahrplanV4EmpfangAktiv']=false;$h->update();check(json_decode($h->vars['NetzfahrplanV4VorschauJSON'],true)['previewBatteryW']===null,'disable clears displayed target');
$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();
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');
advance($h,45);$GLOBALS['getCode']=503;$h->receive();
check($h->attrs['NetzfahrplanV4Empfang']===$saved,'temporary error retains exact original active envelope');
check($h->attrs['NetzfahrplanV4NaechsterAbruf']===$clock+15,'temporary error retries after 15 seconds');
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();
$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();
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();
check($h->attrs['NetzfahrplanV4NaechsterAbruf']===$clock+60,'startup rate-limit response retains ordinary backoff');
echo "Total: $count checks. Mocked IPS and HTTP only.\n";
}
+59
View File
@@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
require_once __DIR__.'/../../libs/NetzfahrplanV4Messsicht.php';
require_once __DIR__.'/fixture.php';
use Belevo\EnelixEMS\NetzfahrplanV4Messaufnahme as Capture;
use Belevo\EnelixEMS\NetzfahrplanV4Messsicht as View;
$n=0;
function ck(bool $v,string $label):void{global $n;if(!$v)throw new RuntimeException('FAIL '.$label);$n++;echo 'PASS '.$label."\n";}
function refuses(callable $f,string $label):void{try{$f();}catch(Throwable $e){ck(true,$label);return;}throw new RuntimeException('Expected refusal: '.$label);}
function take($c,$p,$r,$now):array{return View::calculate(Capture::capture($c,static fn($id)=>$r[$id],static fn()=>$now),$c,$p);}
[$c,$p,$r,$now]=separatedFixture();$x=take($c,$p,$r,$now);
ck($x['physicalLoad']['loadCandidateW']===6000.,'physical sources subtracted once');
ck($x['solarAcAlternative']['loadCandidateW']===6000.,'solar AC terminal used without second battery subtraction');
ck($x['solarOrigin']['reconstructedPvCandidateW']===1000.,'original mantissa scale and battery retained');
ck($x['allocationModel']['unfilteredModelEvW']===2500.&&$x['allocationModel']['unfilteredModelSdlW']===500.,'unfiltered partition uses physical feedback');
ck(!$x['canDispatch']&&!$x['trainingEligible']&&!$x['allocationModel']['separatelyMeasured'],'no measurement or control permission invented');
$a=$r;$a[106]['value']=-9000;$x=take($c,$p,$a,$now);
ck($x['physicalLoad']['status']==='consistent_candidate'&&$x['physicalLoad']['loadCandidateW']===6000.,'held virtual output cannot contaminate physical load');
ck($x['allocationModel']['unfilteredModelEvW']===2500.&&!$x['allocationModel']['reportedAllocationWithinTolerance'],'held EV value only compared not reused');
$a=$r;$a[107]['value']='never-export-this';$x=take($c,$p,$a,$now);
ck($x['physicalLoad']['status']==='consistent_candidate','invalid SDL output does not veto physical load');
ck(!str_contains(json_encode($x),'never-export-this'),'unknown string is not propagated');
$a=$r;$a[108]['value']=42000;$a[109]['value']=-40000;$a[104]['value']=2000;$a[105]['value']=0;$x=take($c,$p,$a,$now);
ck($x['allocationModel']['unfilteredModelEvW']===42000.&&$x['allocationModel']['unfilteredModelSdlW']===-40000.,'opposed commands do not divide by near-zero net');
$a=$r;$a[108]['value']=0;$a[109]['value']=0;$x=take($c,$p,$a,$now);
ck($x['allocationModel']['unfilteredModelEvW']===null&&$x['allocationModel']['unallocatedPhysicalW']===3000.,'idle physical power kept unallocated not invented as EV');
$a=$r;$a[104]['value']=-3125;$a[105]['value']=0;$a[108]['value']=-10500;$a[109]['value']=0;$a[106]['value']=-8334;$a[107]['value']=0;$x=take($c,$p,$a,$now);
ck($x['allocationModel']['unfilteredModelEvW']===-3125.,'real export-example candidate is physical -3125 not held -8334');
ck($x['allocationModel']['status']==='quality_hold'&&in_array('physical_request_tracking_error',$x['allocationModel']['issues'],true),'tracking disagreement remains visible');
$a=$r;$a[108]['changed']=$now;$a[104]['updated']=$now-10;$x=take($c,$p,$a,$now);
ck(in_array('request_newer_than_feedback:req_ev',$x['allocationModel']['issues'],true),'post-command feedback not pretended');
$a=$r;unset($a[108]['changed']);$x=take($c,$p,$a,$now);
ck(in_array('request_change_time_unknown:req_ev',$x['allocationModel']['issues'],true),'unknown command change time explicit');
$a=$r;$a[105]['updated']=$now-70;$x=take($c,$p,$a,$now);
ck($x['storageFeedback']['status']==='quality_hold'&&$x['physicalLoad']['status']==='quality_hold','old physical source blocks physical feedback');
ck($x['solarAcAlternative']['status']==='consistent_candidate','AC alternative does not inherit unrelated solar battery age');
$a=$r;$a[111]['updated']=$now-70;$x=take($c,$p,$a,$now);
ck($x['solarAcAlternative']['status']==='quality_hold'&&$x['physicalLoad']['status']==='quality_hold','fresh solar derived output cannot hide old scale origin');
$a=$r;$a[110]['value']=500;$a[111]['value']=1;$a[103]['value']=6000;$x=take($c,$p,$a,$now);
ck($x['solarOrigin']['acTerminalCandidateW']===5000.&&$x['solarAcAlternative']['loadCandidateW']===11000.,'signed scale applied exactly once');
$a=$r;$a[110]['value']=-500;$a[111]['value']=0;$a[105]['value']=0;$a[103]['value']=0;$x=take($c,$p,$a,$now);
ck($x['solarOrigin']['reconstructedPvCandidateW']===-500.&&$x['solarAcAlternative']['loadCandidateW']===5500.,'negative AC not clamped or disguised as PV');
foreach([true,'0',INF,NAN,-32768]as$invalid){$a=$r;$a[111]['value']=$invalid;$x=take($c,$p,$a,$now);ck($x['solarOrigin']['acTerminalCandidateW']===null,'invalid scale has no numeric fallback');}
$a=$r;$a[110]['value']=-32768;$x=take($c,$p,$a,$now);ck($x['solarOrigin']['acTerminalCandidateW']===null,'SunSpec invalid power sentinel rejected');
$a=$r;$a[102]['updated']=$now-40;$x=take($c,$p,$a,$now);
ck($x['physicalLoad']['status']==='quality_hold'&&$x['storageFeedback']['status']==='consistent_candidate','slow PV does not veto independent storage feedback');
$a=$r;$a[108]['updated']=$now-90;$x=take($c,$p,$a,$now);ck($x['allocationModel']['status']==='quality_hold','old request timestamp visible');
$a=$r;$a[104]['updated']=$now+1;$x=take($c,$p,$a,$now);ck($x['storageFeedback']['status']==='quality_hold','future physical value refused');
$a=$r;$a[104]['parentID']=999;$x=take($c,$p,$a,$now);ck($x['storageFeedback']['physicalStorageCandidateW']===null,'reused measurement identity not accepted');
$a=$r;$a[101]['value']=-20000;$x=take($c,$p,$a,$now);ck($x['physicalLoad']['loadCandidateW']<0&&$x['physicalLoad']['status']==='quality_hold','negative load candidate retained as invalid not zero');
$a=$r;foreach($a as &$v)$v['value']=0;unset($v);$x=take($c,$p,$a,$now);ck($x['physicalLoad']['loadCandidateW']===0.&&$x['physicalLoad']['status']==='consistent_candidate','real zero observation accepted');
$raw=Capture::capture($c,static fn($id)=>$r[$id],static fn()=>$now);$raw['assessment']['baseLoadCandidateW']=999999.;$x=View::calculate($raw,$c,$p);ck($x['physicalLoad']['loadCandidateW']===6000.,'assessment independently recalculated');
foreach(['installationId','mappingSha256','reportedInventorySha256']as$key){$bad=$raw;$bad[$key]='wrong';refuses(fn()=>View::calculate($bad,$c,$p),'wrong identity '.$key);}
$bad=$p;$bad['sdlRequestKey']='req_ev';refuses(fn()=>View::calculate($raw,$c,$bad),'duplicate request channels refused');
$bad=$p;$bad['solarReference']['scaleKey']='soc';refuses(fn()=>View::calculate($raw,$c,$bad),'scale cannot be an arbitrary percent channel');
$before=json_encode([$raw,$c,$p]);View::calculate($raw,$c,$p);ck(json_encode([$raw,$c,$p])===$before,'inputs not mutated');
$src=file_get_contents(__DIR__.'/../../libs/NetzfahrplanV4Messsicht.php');ck(!preg_match('/\b(?:IPS_|GetValue\(|SetValue\(|RequestAction\(|curl_|exec\s*\()/',$src),'pure model has no control or native calls');
echo "TOTAL $n checks passed. Synthetic observations only.\n";
+14
View File
@@ -0,0 +1,14 @@
<?php
function separatedFixture(): array
{
$now=strtotime('2026-10-02T16:00:00Z');
$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'=>[]];
$defs=[['grid','grid',3000],['pv_g','pv',5000],['pv_se','pv',1000],['bat_g','physical_storage',2000],['bat_se','physical_storage',1000],['ev','controlled_virtual',2500],['sdl','external_virtual',500]];
$r=[];
foreach($defs as $i=>[$key,$role,$value]){$id=101+$i;$c['accounting']['sources'][]=['key'=>$key,'role'=>$role,'variableId'=>$id,'parentId'=>200,'ident'=>$key,'factorToW'=>1.,'maxAgeSeconds'=>60,'dependsOn'=>in_array($role,['controlled_virtual','external_virtual'],true)?['bat_g','bat_se']:[]];$r[$id]=['value'=>$value,'updated'=>$now,'changed'=>$now-30,'parentID'=>200,'ident'=>$key];}
foreach([['req_ev','W',2500],['req_sdl','W',500],['se_ac','raw_power',0],['se_sf','scale',0],['soc','percent',50]] as $i=>[$key,$unit,$value]){
$id=108+$i;$c['extraSources'][]=['key'=>$key,'variableId'=>$id,'parentId'=>200,'ident'=>$key,'unit'=>$unit];$r[$id]=['value'=>$value,'updated'=>$now,'changed'=>$now-30,'parentID'=>200,'ident'=>$key];}
$p=['version'=>1,'evRequestKey'=>'req_ev','sdlRequestKey'=>'req_sdl','extraMaxAgeSeconds'=>60,'trackingToleranceW'=>100.,'solarReference'=>['pvKey'=>'pv_se','batteryKey'=>'bat_se','rawKey'=>'se_ac','scaleKey'=>'se_sf']];
return [$c,$p,$r,$now];
}
@@ -0,0 +1,61 @@
<?php
declare(strict_types=1);
require_once __DIR__.'/fixture.php';
$n=0;
function ck($ok,$label){global $n;if(!$ok)throw new RuntimeException('FAIL '.$label);$n++;echo 'PASS '.$label."\n";}
function IPS_GetKernelVersion(){return '8.0';}
function IPS_InstanceExists($id){return $id===17004;}
function IPS_GetInstance($id){if($id!==17004)throw new RuntimeException('foreign instance');return ['ModuleInfo'=>['ModuleID'=>'{6F771B18-59D4-4C8A-B951-3B2FE9F6A2C4}']];}
function IPS_GetChildrenIDs($parent){return array_keys(array_filter($GLOBALS['objects'],fn($o)=>$o['ParentID']===$parent));}
function IPS_GetObject($id){return $GLOBALS['objects'][$id];}
function IPS_GetVariable($id){return $GLOBALS['variables'][$id];}
function IPS_VariableExists($id){return isset($GLOBALS['variables'][$id]);}
function GetValue($id){return $GLOBALS['values'][$id];}
function SetValue($id,$v){if($id<50000||!is_string($v))throw new RuntimeException('FORBIDDEN source/actuator write');$GLOBALS['values'][$id]=$v;return true;}
function IPS_SemaphoreEnter($name,$wait){if($GLOBALS['locks'][$name]??false)return false;$GLOBALS['locks'][$name]=true;return true;}
function IPS_SemaphoreLeave($name){$GLOBALS['locks'][$name]=false;}
function makeObject($type){$id=++$GLOBALS['next'];$GLOBALS['objects'][$id]=['ParentID'=>0,'ObjectIdent'=>'','ObjectType'=>$type,'ObjectInfo'=>''];return $id;}
function IPS_CreateCategory(){return makeObject(0);}
function IPS_CreateVariable($type){ck($type===3,'only new textual observation variables');$id=makeObject(2);$GLOBALS['variables'][$id]=['VariableType'=>3];$GLOBALS['values'][$id]='';return $id;}
function IPS_CreateScript($type){if($type!==0)throw new RuntimeException('script type');$id=makeObject(3);$GLOBALS['scripts'][$id]='';return $id;}
function changeObject($id,$key,$v){if($id<50000)throw new RuntimeException('FORBIDDEN existing object change');$GLOBALS['objects'][$id][$key]=$v;return true;}
function IPS_SetParent($id,$v){return changeObject($id,'ParentID',$v);}
function IPS_SetIdent($id,$v){return changeObject($id,'ObjectIdent',$v);}
function IPS_SetName($id,$v){return changeObject($id,'ObjectName',$v);}
function IPS_SetInfo($id,$v){return changeObject($id,'ObjectInfo',$v);}
function IPS_GetScriptContent($id){return $GLOBALS['scripts'][$id];}
function IPS_SetScriptContent($id,$s){if($id<50000)throw new RuntimeException('existing script');$GLOBALS['scripts'][$id]=$s;return true;}
function IPS_SetScriptTimer($id,$seconds){if($id<50000||!isset($GLOBALS['scripts'][$id])||!in_array($seconds,[0,30],true))throw new RuntimeException('FORBIDDEN timer change');$GLOBALS['timers'][$id]=$seconds;return true;}
[$c,$p,$r,$ignored]=separatedFixture();$now=time();$objects=[];$variables=[];$values=[];$scripts=[];$timers=[];$locks=[];$next=50000;
foreach($r as$id=>$v){$objects[$id]=['ParentID'=>$v['parentID'],'ObjectIdent'=>$v['ident'],'ObjectType'=>2,'ObjectInfo'=>'original'];$variables[$id]=['VariableType'=>is_int($v['value'])?1:2,'VariableUpdated'=>$now,'VariableChanged'=>$now-30];$values[$id]=$v['value'];}
$sourceValues=$values;$base=sys_get_temp_dir().'/v4views-test-'.bin2hex(random_bytes(6));mkdir($base,0700);mkdir($base.'/data',0700);
$files=['source/libs/NetzfahrplanV4Bilanzierung.php','source/libs/NetzfahrplanV4Messaufnahme.php','source/libs/NetzfahrplanV4Messsicht.php','source/examples/V4SeparatedObservation/runtime.php','source/examples/V4SeparatedObservation/install.php'];
foreach($files as$name){$dest=$base.'/'.$name;if(!is_dir(dirname($dest)))mkdir(dirname($dest),0700,true);copy(__DIR__.'/../../'.substr($name,7),$dest);}
file_put_contents($base.'/INVENTORY.json','{"effectiveEvCapacityKwh":161.44,"evPowerKw":39}');$c['reportedInventorySha256']=hash_file('sha256',$base.'/INVENTORY.json');
file_put_contents($base.'/config.json',json_encode($c,JSON_THROW_ON_ERROR|JSON_PRESERVE_ZERO_FRACTION));file_put_contents($base.'/policy.json',json_encode($p,JSON_THROW_ON_ERROR|JSON_PRESERVE_ZERO_FRACTION));
$manifest=['scope'=>'passive_separated_observation','hashes'=>[]];foreach(array_merge($files,['config.json','policy.json','INVENTORY.json'])as$name)$manifest['hashes'][$name]=hash_file('sha256',$base.'/'.$name);file_put_contents($base.'/MANIFEST.json',json_encode($manifest));
require_once $base.'/source/examples/V4SeparatedObservation/install.php';
try{
ob_start();$report=v4viewsInstall($base);ob_end_clean();ck($report['status']==='observer_installed','native installation completes');$cat=$report['categoryId'];
ck(count($report['createdObjects'])===6,'one new category three read-only variables two scripts');
ck($timers===[$report['scriptId']=>30],'only new passive observation timer');
ck($report['firstObservation']['status']==='recorded','actual first record precedes timer');
$journal=$report['firstObservation']['journal'];$original=file_get_contents($journal);
ck((fileperms($journal)&0777)===0644&&(fileperms(dirname($journal))&0077)===0,'selected new output readable inside private directory');
foreach($sourceValues as$id=>$value)ck($values[$id]===$value,'source unchanged '.$id);
ob_start();$again=v4viewsInstall($base);ob_end_clean();ck($again['createdObjects']===[],'idempotent no duplicate scripts or variables');
ck(str_starts_with(file_get_contents($journal),$original),'repeat preserves historical observations');
$before=file_get_contents($journal);$locks['ENELIX.V4.SeparatedView.'.$c['installationId']]=true;$busy=v4viewsRun($base,$cat);ck($busy['status']==='busy'&&file_get_contents($journal)===$before,'parallel observer does not duplicate writes');$locks=[];
unset($variables[101]);$failed=v4viewsRun($base,$cat);ck($failed['status']==='recorded','missing measurement explicitly recorded');
$latest=json_decode(file_get_contents($base.'/data/LATEST.json'),true);ck($latest['views']['physicalLoad']['loadCandidateW']===null&&!$latest['controlEnabled'],'bad source never old target or fake zero');$variables[101]=['VariableType'=>1,'VariableUpdated'=>$now,'VariableChanged'=>$now-30];
$before=file_get_contents($journal);v4viewsStop($base,$cat);ck($timers[$report['scriptId']]===0&&file_get_contents($journal)===$before,'stop only observer no deletion');
ck(json_decode(file_get_contents($base.'/data/LATEST.json'),true)['status']==='stopped','stopped view clears latest numbers');
$scripts[$report['scriptId']].='// parallel edit';ob_start();$bad=v4viewsInstall($base);ob_end_clean();ck($bad['status']==='needs_review'&&$timers[$report['scriptId']]===0,'parallel script edit not overwritten or activated');
$old=file_get_contents($base.'/config.json');file_put_contents($base.'/config.json',$old.' ');try{v4viewsConfig($base);$refused=false;}catch(Throwable $e){$refused=true;}ck($refused,'changed config refused');file_put_contents($base.'/config.json',$old);
chmod($base,0755);clearstatcache();try{v4viewsConfig($base);$refused=false;}catch(Throwable $e){$refused=true;}ck($refused,'public stage refused before reading output');chmod($base,0700);
foreach(['runtime.php','install.php']as$file){$text=file_get_contents($base.'/source/examples/V4SeparatedObservation/'.$file);ck(!preg_match('/\b(?:RequestAction|IPS_ApplyChanges|IPS_SetProperty|IPS_SetConfiguration|MC_Reload|curl_exec|shell_exec)\s*\(/',$text),'no actuation or configuration calls in '.$file);}
}finally{
foreach(new RecursiveIteratorIterator(new RecursiveDirectoryIterator($base,FilesystemIterator::SKIP_DOTS),RecursiveIteratorIterator::CHILD_FIRST)as$f){$f->isDir()&&!$f->isLink()?rmdir($f->getPathname()):unlink($f->getPathname());}rmdir($base);
}
echo "TOTAL $n native-mock checks passed. No actual Symcon calls.\n";
+140
View File
@@ -0,0 +1,140 @@
<?php
declare(strict_types=1);
namespace Belevo\EnelixEMS\Tests;
use Belevo\EnelixEMS\NetzfahrplanV4Betriebsdaten;
use RuntimeException;
use InvalidArgumentException;
/** Runs offline. No IPS API, network, physical devices or production settings. */
final class NativeV4Scenarios
{
public static function run(): array
{
$now = strtotime('2026-10-01T12:05:00Z');
$m = ['NetzleistungVariableID' => 1, 'NetzbezugEnergieVariableID' => 9,
'Netzleistungsfaktor' => 1.0, 'MesswertMaxAlter' => 60, 'Lastspitzenmodus' => 1,
'Lastspitzengrenze' => 25000.0, 'EinspeisebegrenzungAktiv' => true, 'Einspeisegrenze' => 25000.0];
$m['V4BezugszaehlerQuellen'] = [
['VariableID' => 6, 'ElternID' => 99, 'Ident' => 'Energy_0', 'FaktorZuKWh' => 1.0, 'Messgroesse' => 'WirkenergieBezug'],
['VariableID' => 7, 'ElternID' => 99, 'Ident' => 'Energy_1', 'FaktorZuKWh' => 1.0, 'Messgroesse' => 'WirkenergieBezug'],
];
$a = [['ID' => 'ev', 'Nennkapazitaet_kWh' => 161.44, 'Nutzkapazitaet_kWh' => 161.44,
'MaxLadeleistung_kW' => 39.0, 'MaxEntladeleistung_kW' => 30.0, 'SOCVariableID' => 2, 'LeistungVariableID' => 5]];
$c = [['InstanzID' => 42, 'LadezustandVariableID' => 2, 'MaxLadeleistungVariableID' => 3,
'MaxEntladeleistungVariableID' => 4, 'IstleistungVariableID' => 5,
'ReserveLadezustand' => 15.0, 'MindestLadezustand' => 3.0, 'MesswertMaxAlter' => 30,
'Verfuegbar' => true, 'Batteriemanagement' => 2, 'EmpfangenAm' => $now]];
$read = static function (int $id) use ($now): array {
return ['value' => [1 => -5332.0, 2 => 19.0, 3 => 39000.0, 4 => 35000.0, 6 => 3992.203, 7 => 0.0][$id],
'updated' => $now, 'parentID' => 99, 'ident' => [6 => 'Energy_0', 7 => 'Energy_1'][$id] ?? 'other'];
};
$check = static function (bool $ok): void { if (!$ok) { throw new RuntimeException('Assertion failed'); } };
$fail = static function (callable $fn): void {
try { $fn(); } catch (InvalidArgumentException $e) { return; }
throw new RuntimeException('Expected invalid-input rejection');
};
$cases = [];
$build = static function ($mm = null, $aa = null, $cc = null, $r = null, $e = []) use ($m, $a, $c, $read, $now): array {
return NetzfahrplanV4Betriebsdaten::erstellen($mm ?? $m, $aa ?? $a, $cc ?? $c, $r ?? $read, $e, $now);
};
$cases['native_mapping_and_asymmetric_limits'] = static function () use ($build, $check): void {
$x = $build()['operation']; $b = $x['batteries'][0];
$check($x['gridW'] === -5332.0 && $b['capacityKwh'] === 161.44 && $b['minSocPercent'] === 15.0
&& $b['maxChargeW'] === 39000.0 && $b['maxDischargeW'] === 30000.0);
};
$cases['manager_cap_not_paid_peak'] = static function () use ($build, $check): void {
$x = $build()['operation']; $check($x['limits']['importW'] === 25000.0 && (array) $x['measuredPeaks'] === [] && $x['quarterPast'] === null);
};
$cases['empty_maps_are_objects'] = static function () use ($build, $check): void {
$x = json_decode(json_encode($build()['operation'])); $check(is_object($x->measuredPeaks) && is_object($x->limits->managerMonthLimitsW));
};
$cases['zero_limits_not_unlimited'] = static function () use ($build, $m, $check): void {
$m['Einspeisegrenze'] = 0.0; $m['Lastspitzengrenze'] = 0.0; $x = $build($m)['operation'];
$check($x['limits']['importW'] === 0.0 && $x['limits']['exportW'] === 0.0);
};
$cases['disabled_limits_are_null'] = static function () use ($build, $m, $check): void {
$m['EinspeisebegrenzungAktiv'] = false; $m['Lastspitzenmodus'] = 0; $x = $build($m)['operation'];
$check($x['limits']['importW'] === null && $x['limits']['exportW'] === null);
};
$cases['all_monthly_limits'] = static function () use ($build, $m, $check, $fail): void {
$m['Lastspitzenmodus'] = 2; $m['Monatsgrenzen'] = [];
for ($i = 1; $i <= 12; $i++) { $m['Monatsgrenzen'][] = ['MonatIndex' => $i, 'Grenze_W' => $i * 1000]; }
$x = $build($m)['operation']; $check($x['limits']['managerMonthLimitsW']->{'10'} === 10000.0);
array_pop($m['Monatsgrenzen']); $fail(static fn() => $build($m));
};
$cases['no_unconfirmed_soc_capacity'] = static function () use ($build, $a, $m, $fail, $check): void {
$a[0]['Nutzkapazitaet_kWh'] = 140.; $fail(static fn() => $build(null, $a));
$m['BatterieOptionen'] = ['ev' => ['SOCKapazitaet_kWh' => 161.44]];
$check($build($m, $a)['operation']['batteries'][0]['capacityKwh'] === 161.44);
};
$cases['no_duplicate_controller'] = static function () use ($build, $a, $fail): void {
$a[] = array_replace($a[0], ['ID' => 'other']); $fail(static fn() => $build(null, $a));
};
$cases['no_ambiguous_mapping'] = static function () use ($build, $c, $fail): void {
$c[] = array_replace($c[0], ['InstanzID' => 43]); $fail(static fn() => $build(null, null, $c));
};
$cases['mismatched_power_source'] = static function () use ($build, $c, $fail): void {
$c[0]['IstleistungVariableID'] = 100; $fail(static fn() => $build(null, null, $c));
};
$cases['stale_measurements_fail'] = static function () use ($build, $read, $now, $fail): void {
$r = static function ($id) use ($read, $now) { $v = $read($id); $v['updated'] = $now - 61; return $v; };
$fail(static fn() => $build(null, null, null, $r));
};
$cases['stale_consumer_cache_fails'] = static function () use ($build, $c, $now, $fail): void {
$c[0]['EmpfangenAm'] = $now - 61; $fail(static fn() => $build(null, null, $c));
};
$cases['soc_below_reserve_is_not_fabricated'] = static function () use ($build, $read, $check): void {
$r = static function ($id) use ($read) { $v = $read($id); if ($id === 2) { $v['value'] = 5.; } return $v; };
$b = $build(null, null, null, $r)['operation']['batteries'][0];
$check($b['socPercent'] === 5.0 && $b['minSocPercent'] === 15.0 && $b['physicalMinSocPercent'] === 3.0 && $b['recoveryAllowed'] === true && $b['dischargeBlocked'] === true);
};
$cases['unavailable_asset_no_power'] = static function () use ($build, $c, $check): void {
$c[0]['Verfuegbar'] = false; $x = $build(null, null, $c)['operation']['batteries'][0];
$check($x['maxChargeW'] === 0.0 && $x['maxDischargeW'] === 0.0);
};
$cases['hysteresis_not_silently_ignored'] = static function () use ($build, $c, $check): void {
$c[0]['HystereseAktiv'] = true; $x = $build(null, null, $c);
$check($x['operation']['batteries'][0]['dischargeBlocked'] === true && $x['operation']['batteries'][0]['maxDischargeW'] === 30000.0 && count($x['warnings']) > 1);
};
$cases['grid_charge_explicit_opt_in'] = static function () use ($build, $m, $check): void {
$check($build()['operation']['batteries'][0]['gridCharging'] === false);
$m['NetzladenErlaubt'] = true; $check($build($m)['operation']['batteries'][0]['gridCharging'] === true);
};
$ev = ['version' => 1, 'meterId' => \Belevo\EnelixEMS\NetzfahrplanV4Bezugszaehler::identitaet($m['V4BezugszaehlerQuellen']), 'measuredAt' => gmdate('c', $now),
'measuredPeaks' => ['2026-10' => ['kw' => 18.4, 'source' => 'meter_month_register']],
'quarterPast' => ['start' => '2026-10-01T12:00:00Z', 'measuredSeconds' => 300, 'importKwh' => 0.5]];
$cases['actual_meter_evidence_used'] = static function () use ($build, $ev, $check): void {
$x = $build(null, null, null, null, $ev);
$check($x['status'] === 'ready_shadow' && $x['operation']['measuredPeaks']->{'2026-10'}['kw'] === 18.4 && $x['operation']['quarterPast']['importKwh'] === 0.5);
};
$cases['stale_quarter_not_extrapolated'] = static function () use ($build, $ev, $now, $check): void {
$ev['measuredAt'] = gmdate('c', $now - 1); $x = $build(null, null, null, null, $ev);
$check($x['operation']['quarterPast'] === null && count($x['warnings']) > 0);
};
$cases['wrong_meter_evidence_fails'] = static function () use ($build, $ev, $fail): void {
$ev['meterId'] = 'symcon:100'; $fail(static fn() => $build(null, null, null, null, $ev));
};
$cases['planned_peak_not_evidence'] = static function () use ($build, $ev, $fail): void {
$ev['measuredPeaks']['2026-10']['source'] = 'manager_cap'; $fail(static fn() => $build(null, null, null, null, $ev));
};
$cases['deterministic_retry_event_id'] = static function () use ($build, $check): void {
$check($build()['operation']['eventId'] === $build()['operation']['eventId']);
};
$cases['legacy_meter_identity_not_accepted'] = static function () use ($build, $ev, $fail): void {
$ev['meterId'] = 'symcon:9'; $fail(static fn() => $build(null, null, null, null, $ev));
};
$cases['missing_v4_sources_never_use_legacy'] = static function () use ($build, $m, $fail): void {
unset($m['V4BezugszaehlerQuellen']); $fail(static fn() => $build($m));
};
$cases['counter_snapshot_is_not_billing_evidence'] = static function () use ($build, $check): void {
$v = $build(); $check($v['bezugszaehler']['totalKwh'] === 3992.203
&& !$v['bezugszaehler']['billingEvidence'] && (array) $v['operation']['measuredPeaks'] === []);
};
$passed = [];
foreach ($cases as $name => $fn) { $fn(); $passed[] = $name; }
return $passed;
}
}
+113
View File
@@ -0,0 +1,113 @@
<?php
declare(strict_types=1);
namespace Belevo\EnelixEMS\Tests;
use Belevo\EnelixEMS\NetzfahrplanV4Bezugszaehler as Meter;
use InvalidArgumentException;
use RuntimeException;
/** Offline only: injected readings, no Symcon, archive, network or actuator access. */
final class V4BezugszaehlerScenarios
{
public static function run(): array
{
$now = 1790874000;
$sources = [
['VariableID' => 59607, 'ElternID' => 11490, 'Ident' => 'Energy_0', 'FaktorZuKWh' => 1.0, 'Messgroesse' => 'WirkenergieBezug'],
['VariableID' => 26620, 'ElternID' => 11490, 'Ident' => 'Energy_1', 'FaktorZuKWh' => 1.0, 'Messgroesse' => 'WirkenergieBezug'],
];
$samples = [
59607 => ['value' => 3992.203, 'updated' => $now, 'parentID' => 11490, 'ident' => 'Energy_0'],
26620 => ['value' => 0.0, 'updated' => $now, 'parentID' => 11490, 'ident' => 'Energy_1'],
];
$check = static function (bool $value): void { if (!$value) { throw new RuntimeException('Assertion failed'); } };
$reject = static function (callable $fn): void {
try { $fn(); } catch (InvalidArgumentException $e) { return; }
throw new RuntimeException('Expected invalid source/sample rejection');
};
$snapshot = static function ($ss = null, $mm = null) use ($sources, $samples, $now): array {
$mm = $mm ?? $samples;
return Meter::lesen($ss ?? $sources, static fn(int $id) => $mm[$id] ?? null, $now);
};
$cases = [];
$cases['zero_t2_is_valid_but_not_history'] = static function () use ($snapshot, $check): void {
$v = $snapshot();
$check(abs($v['totalKwh'] - 3992.203) < 1e-9 && !$v['historyComplete'] && !$v['billingEvidence']);
$check(!isset($v['measuredPeaks']) && !isset($v['quarterPast']));
};
$cases['both_tariffs_are_summed'] = static function () use ($snapshot, $samples, $check): void {
$samples[26620]['value'] = 27.25;
$check(abs($snapshot(null, $samples)['totalKwh'] - 4019.453) < 1e-9);
};
$cases['zero_total_not_replaced'] = static function () use ($snapshot, $samples, $check): void {
$samples[59607]['value'] = 0;
$check($snapshot(null, $samples)['totalKwh'] === 0.0);
};
$cases['source_order_does_not_change_identity'] = static function () use ($sources, $check): void {
$check(Meter::identitaet($sources) === Meter::identitaet(array_reverse($sources)));
};
$cases['changed_factor_invalidates_identity'] = static function () use ($sources, $check): void {
$old = Meter::identitaet($sources); $sources[0]['FaktorZuKWh'] = 0.001;
$check($old !== Meter::identitaet($sources));
};
$cases['changed_variable_invalidates_identity'] = static function () use ($sources, $check): void {
$old = Meter::identitaet($sources); $sources[0]['VariableID'] = 12345;
$check($old !== Meter::identitaet($sources));
};
$cases['no_fallback_to_legacy_source'] = static function () use ($snapshot, $reject): void { $reject(static fn() => $snapshot([])); };
$cases['duplicate_variable_rejected'] = static function () use ($sources, $snapshot, $reject): void {
$sources[1] = $sources[0]; $reject(static fn() => $snapshot($sources));
};
$cases['different_meter_boundary_rejected'] = static function () use ($sources, $snapshot, $reject): void {
$sources[1]['ElternID'] = 48065; $reject(static fn() => $snapshot($sources));
};
$cases['reactive_quantity_rejected'] = static function () use ($sources, $snapshot, $reject): void {
$sources[0]['Messgroesse'] = 'Blindenergie'; $reject(static fn() => $snapshot($sources));
};
$cases['explicit_conversion_required'] = static function () use ($sources, $snapshot, $reject): void {
unset($sources[0]['FaktorZuKWh']); $reject(static fn() => $snapshot($sources));
};
$cases['missing_tariff_not_zero'] = static function () use ($snapshot, $samples, $reject): void {
unset($samples[26620]); $reject(static fn() => $snapshot(null, $samples));
};
$cases['stale_zero_tariff_rejected'] = static function () use ($snapshot, $samples, $reject): void {
$samples[26620]['updated'] -= 61; $reject(static fn() => $snapshot(null, $samples));
};
$cases['future_reading_rejected'] = static function () use ($snapshot, $samples, $reject): void {
$samples[26620]['updated']++; $reject(static fn() => $snapshot(null, $samples));
};
$cases['wrong_ident_rejected'] = static function () use ($snapshot, $samples, $reject): void {
$samples[59607]['ident'] = 'Energy_6'; $reject(static fn() => $snapshot(null, $samples));
};
$cases['wrong_parent_rejected'] = static function () use ($snapshot, $samples, $reject): void {
$samples[59607]['parentID'] = 48065; $reject(static fn() => $snapshot(null, $samples));
};
$cases['invalid_numbers_rejected'] = static function () use ($snapshot, $samples, $reject): void {
foreach ([false, '3992.203', NAN, INF, -1.0] as $bad) {
$samples[59607]['value'] = $bad; $reject(static fn() => $snapshot(null, $samples));
}
};
$cases['skewed_tariff_observations_rejected'] = static function () use ($snapshot, $samples, $reject): void {
$samples[59607]['updated'] -= 3; $reject(static fn() => $snapshot(null, $samples));
};
$cases['concurrent_update_in_same_second_rejected'] = static function () use ($sources, $samples, $now, $reject): void {
$calls = 0;
$read = static function (int $id) use ($samples, &$calls): array {
$v = $samples[$id]; if (++$calls > 2) { $v['value'] += 0.1; } return $v;
};
$reject(static fn() => Meter::lesen($sources, $read, $now));
};
$cases['observation_not_fake_exact_billing_time'] = static function () use ($snapshot, $samples, $now, $check): void {
$samples[59607]['updated'] -= 1;
$v = $snapshot(null, $samples);
$check($v['sourceObservationFrom'] === gmdate('c', $now - 1));
$check($v['sourceObservationUntil'] === gmdate('c', $now) && $v['observedAt'] === gmdate('c', $now));
$check(!$v['billingEvidence']);
};
$passed = [];
foreach ($cases as $name => $fn) { $fn(); $passed[] = $name; }
return $passed;
}
}