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