From 86834bb8aab51beba34d93574327d778d2c44521 Mon Sep 17 00:00:00 2001 From: dh_Agent Date: Thu, 8 Oct 2026 07:24:19 +0000 Subject: [PATCH] fix(compat): preserve PHP 8.0 support without changing control guards Replace trait constants and array_is_list usage with equivalent PHP 8.0 constructs. Add strict list/expiry regressions and make isolated reflection tests PHP 8.0 compatible. No control activation, threshold or configuration change. --- Manager/module.php | 2 +- libs/ManagerNetzfahrplanV4AktivtestTrait.php | 20 +++-- libs/ManagerSDLTrait.php | 2 +- libs/NetzfahrplanV4Rueckmeldung.php | 4 +- tests/NetzfahrplanV4RegeltestTest.php | 2 +- tests/Php80Lists/checks.php | 89 ++++++++++++++++++++ tests/Php80ListsTest.php | 26 ++++++ tests/V4ControlTrial/checks.php | 16 ++++ tests/V4ControlTrial/register_checks.php | 1 + tests/V4Feedback/message_checks.php | 1 + 10 files changed, 152 insertions(+), 11 deletions(-) create mode 100644 tests/Php80Lists/checks.php create mode 100644 tests/Php80ListsTest.php diff --git a/Manager/module.php b/Manager/module.php index b509adb..4a553e1 100644 --- a/Manager/module.php +++ b/Manager/module.php @@ -2108,7 +2108,7 @@ class Manager extends IPSModule implements ManagerSchnittstelle private function leseAnlagenKonfigurationsliste(string $property): array { $liste = json_decode($this->ReadPropertyString($property), true, 512, JSON_THROW_ON_ERROR); - if (!is_array($liste) || !array_is_list($liste)) { + if (!is_array($liste) || array_keys($liste) !== array_keys(array_values($liste))) { throw new InvalidArgumentException($property . ' muss eine JSON-Liste sein.'); } diff --git a/libs/ManagerNetzfahrplanV4AktivtestTrait.php b/libs/ManagerNetzfahrplanV4AktivtestTrait.php index 4de49a7..d75c9c9 100644 --- a/libs/ManagerNetzfahrplanV4AktivtestTrait.php +++ b/libs/ManagerNetzfahrplanV4AktivtestTrait.php @@ -12,8 +12,16 @@ require_once __DIR__ . '/NetzfahrplanV4Regeltest.php'; /** Locally approved, time-boxed operation of validated V4 shadow plans on a test plant. */ trait ManagerNetzfahrplanV4AktivtestTrait { - private const V4_AKTIVTEST_DAUER_SEKUNDEN = 172800; - private const V4_AKTIVTEST_WATCHDOG_VERZICHT = 'explicit_test_plant_watchdog_waiver'; + // Trait constants require PHP 8.2; keep these fixed values compatible with PHP 8.0. + private static function v4AktivtestDauerSekunden(): int + { + return 172800; + } + + private static function v4AktivtestWatchdogVerzicht(): string + { + return 'explicit_test_plant_watchdog_waiver'; + } private function registriereV4ManagerAktivtest(): void { @@ -78,12 +86,12 @@ trait ManagerNetzfahrplanV4AktivtestTrait $until = $this->ReadAttributeInteger('NetzfahrplanV4AktivtestEnde'); if ($start === 0 && $until === 0) { $start = $now; - $until = $now + self::V4_AKTIVTEST_DAUER_SEKUNDEN; + $until = $now + self::v4AktivtestDauerSekunden(); $this->WriteAttributeInteger('NetzfahrplanV4AktivtestBeginn', $start); $this->WriteAttributeInteger('NetzfahrplanV4AktivtestEnde', $until); } if ($start <= 0 || $until <= $now - || $until - $start > self::V4_AKTIVTEST_DAUER_SEKUNDEN) { + || $until - $start > self::v4AktivtestDauerSekunden()) { throw new RuntimeException('Die einmalige 48-Stunden-Aktivtestfreigabe ist abgelaufen.'); } @@ -103,7 +111,7 @@ trait ManagerNetzfahrplanV4AktivtestTrait 'maxChargeW' => $maxChargeW, 'maxDischargeW' => $maxDischargeW, 'watchdogWaived' => true, - 'actuatorWatchdogEvidenceId' => self::V4_AKTIVTEST_WATCHDOG_VERZICHT, + 'actuatorWatchdogEvidenceId' => self::v4AktivtestWatchdogVerzicht(), 'feedbackConfigHash' => $operation['localFeedback'][$asset['ID']]['configHash'] ?? '', 'gridLimits' => NetzfahrplanV4Planpruefung::kontext($operation)['limits'], 'sequence' => 1, @@ -206,7 +214,7 @@ trait ManagerNetzfahrplanV4AktivtestTrait 'maxChargeW' => $session['maxChargeW'], 'maxDischargeW' => $session['maxDischargeW'], 'accountingEvidenceId' => $quality['accountingEvidenceId'] ?? null, - 'actuatorWatchdogEvidenceId' => self::V4_AKTIVTEST_WATCHDOG_VERZICHT, + 'actuatorWatchdogEvidenceId' => self::v4AktivtestWatchdogVerzicht(), 'acceptEstimatedPeak' => true, 'controlContext' => $plan['controlContext'] ?? null, ]; diff --git a/libs/ManagerSDLTrait.php b/libs/ManagerSDLTrait.php index 3855e75..10e603b 100644 --- a/libs/ManagerSDLTrait.php +++ b/libs/ManagerSDLTrait.php @@ -128,7 +128,7 @@ trait ManagerSDLTrait ); } $alt = json_decode(IPS_GetProperty($instanzID, 'Config'), true, 512, JSON_THROW_ON_ERROR); - if (!is_array($alt) || !array_is_list($alt)) { + if (!is_array($alt) || array_keys($alt) !== array_keys(array_values($alt))) { throw new RuntimeException('Die bestehende Energiefluss-Konfiguration ist keine Liste.'); } $neu = []; diff --git a/libs/NetzfahrplanV4Rueckmeldung.php b/libs/NetzfahrplanV4Rueckmeldung.php index df89be3..fd60a1e 100644 --- a/libs/NetzfahrplanV4Rueckmeldung.php +++ b/libs/NetzfahrplanV4Rueckmeldung.php @@ -18,7 +18,7 @@ final class NetzfahrplanV4Rueckmeldung private static function ordered(array $v): array { - if (!array_is_list($v)) ksort($v, SORT_STRING); + if (array_keys($v) !== array_keys(array_values($v))) ksort($v, SORT_STRING); foreach ($v as &$x) if (is_array($x)) $x=self::ordered($x); unset($x);return $v; } @@ -40,7 +40,7 @@ final class NetzfahrplanV4Rueckmeldung } $c['idleToleranceW']=self::number($c['idleToleranceW'],0,100); $c['trackingToleranceW']=self::number($c['trackingToleranceW'],0,500); - if (!is_array($c['sources']) || !array_is_list($c['sources']) || count($c['sources'])<2 || count($c['sources'])>40) throw new InvalidArgumentException('feedback_sources_invalid'); + if (!is_array($c['sources']) || array_keys($c['sources']) !== array_keys(array_values($c['sources'])) || count($c['sources'])<2 || count($c['sources'])>40) throw new InvalidArgumentException('feedback_sources_invalid'); $ids=[];$names=[];$roles=[]; foreach ($c['sources'] as &$s) { $fields=['key','role','variableId','parentId','ident','factorToW']; diff --git a/tests/NetzfahrplanV4RegeltestTest.php b/tests/NetzfahrplanV4RegeltestTest.php index d5dfcf5..00ad374 100644 --- a/tests/NetzfahrplanV4RegeltestTest.php +++ b/tests/NetzfahrplanV4RegeltestTest.php @@ -19,6 +19,6 @@ final class NetzfahrplanV4RegeltestTest extends TestCase fclose($pipes[1]);fclose($pipes[2]); self::assertSame(0, proc_close($process), $out . $err); self::assertSame('', $err); - self::assertStringContainsString('TOTAL 96 checks passed.', $out); + self::assertStringContainsString('TOTAL 101 checks passed.', $out); } } diff --git a/tests/Php80Lists/checks.php b/tests/Php80Lists/checks.php new file mode 100644 index 0000000..1419760 --- /dev/null +++ b/tests/Php80Lists/checks.php @@ -0,0 +1,89 @@ +setAccessible(true); + return $method->invokeArgs($object, $arguments); +} +function accepted(callable $call, string $message): bool +{ + try { $call(); return true; } + catch (RuntimeException | InvalidArgumentException $error) { + if ($error->getMessage() !== $message) throw $error; + return false; + } +} + +$row = ['ID' => 'example', 'VariableID' => 12345]; +$cases = [ + 'empty' => [[], true], + 'one' => [[$row], true], + 'two' => [[$row, $row], true], + 'sparse' => [[0 => $row, 2 => $row], false], + 'one-based' => [[1 => $row, 2 => $row], false], + 'out-of-order' => [[1 => $row, 0 => $row], false], + 'negative-key' => [[-1 => $row, 0 => $row], false], + 'string-key' => [['source' => $row], false], + 'mixed-keys' => [[0 => $row, 'source' => $row], false], + 'scalar' => [false, false], + 'null' => [null, false], +]; +foreach ($cases as $name => [$value, $expected]) { + ManagerSDLRuntime::reset(); + $manager = new Manager(42); + $manager->Create(); + $json = json_encode($value, JSON_THROW_ON_ERROR); + $manager->testProperties['AnlagenBatterien'] = $json; + $result = accepted(static function () use ($manager, $value, $expected): void { + $actual = invokePrivate($manager, 'leseAnlagenKonfigurationsliste', ['AnlagenBatterien']); + if ($expected) verify($actual === $value, 'valid manager list unchanged'); + }, 'AnlagenBatterien muss eine JSON-Liste sein.'); + verify($result === $expected, 'manager list: ' . $name); + + ManagerSDLRuntime::child(42, 'EnergieflussSDL'); + ManagerSDLRuntime::$properties[700]['Config'] = $json; + $result = accepted(static fn() => invokePrivate($manager, 'synchronisiereSDLEnergiefluss', [700]), + 'Die bestehende Energiefluss-Konfiguration ist keine Liste.'); + verify($result === $expected, 'SDL list: ' . $name); + verify(ManagerSDLRuntime::$writes === [], 'list validation cannot change SDL configuration'); + verify(ManagerSDLRuntime::$properties[700]['Config'] === $json, 'SDL source remains byte-identical'); +} + +$config = ['version'=>1, 'installationId'=>'e3a08f9e-af12-4695-99bd-8b51c0520021', + 'assetId'=>'ev', 'managerId'=>17004, 'batteryInstanceId'=>44234, 'mode'=>'physical_sum', + 'allowEstimatedForTrial'=>false, 'maxSkewSeconds'=>30, 'maxAgeSeconds'=>60, + 'idleToleranceW'=>50.0, 'trackingToleranceW'=>200.0, 'sources'=>[]]; +foreach (['grid', 'physical'] as $i => $role) { + $config['sources'][] = ['key'=>'source'.$i, 'role'=>$role, 'variableId'=>100+$i, + 'parentId'=>200, 'ident'=>'value'.$i, 'factorToW'=>1]; +} +$hash = Feedback::fingerprint($config); +$reordered = array_reverse($config, true); +$reordered['sources'] = array_reverse($config['sources']); +verify(Feedback::fingerprint($reordered) === $hash, 'canonical configuration fingerprint unchanged'); +foreach ([[], [1, 2], [0, 2], [1, 0], ['grid', 'physical']] as $keys) { + $bad = $config; + $bad['sources'] = $keys === [] ? [] : array_combine($keys, $config['sources']); + verify(!accepted(static fn() => Feedback::configuration($bad), 'feedback_sources_invalid'), + 'malformed feedback source keys remain rejected'); +} +echo 'PHP80_LISTS_OK ' . $count . " checks; real methods with isolated runtime only.\n"; diff --git a/tests/Php80ListsTest.php b/tests/Php80ListsTest.php new file mode 100644 index 0000000..f97e75d --- /dev/null +++ b/tests/Php80ListsTest.php @@ -0,0 +1,26 @@ + ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes); + self::assertIsResource($process); + fclose($pipes[0]); + $out = stream_get_contents($pipes[1]); + $err = stream_get_contents($pipes[2]); + fclose($pipes[1]); + fclose($pipes[2]); + self::assertSame(0, proc_close($process), $out . $err); + self::assertSame('', $err); + self::assertStringContainsString('PHP80_LISTS_OK ', $out); + } +} diff --git a/tests/V4ControlTrial/checks.php b/tests/V4ControlTrial/checks.php index 1a9e8e7..20b0de0 100644 --- a/tests/V4ControlTrial/checks.php +++ b/tests/V4ControlTrial/checks.php @@ -210,6 +210,9 @@ namespace { $bad=$r;$bad['controlledTrial']['acceptEstimatedPeak']=false;rejects(fn()=>Gate::befehl($bad,$op,['b'=>0.],$local,$now,$now),'peak uncertainty needs explicit acceptance'); [$m,$b]=activeFixture();$m->V4ManagerAktivtestSchalten(true); check($m->active()&&$b->active()&&$m->vars['NetzfahrplanV4Aktivtest'],'single runtime switch starts bounded active test'); + $activeSession=json_decode($m->buf['V4ManagerTrialSession'],true); + check($activeSession['actuatorWatchdogEvidenceId']==='explicit_test_plant_watchdog_waiver', + 'active session retains exact explicit watchdog waiver identifier'); $allocation=tick($m); check(end($b->writes)===3000&&!isset($allocation['Sollwerte'][44234]),'active test uses guarded command path'); check($m->command()['executionMeaning']==='explicit_time_boxed_test_plant_operation','active command is explicitly distinguished'); @@ -231,6 +234,19 @@ namespace { check($m->active()&&$b->active(),'local active authority refreshes while plan and measurements remain valid'); $m->V4ManagerAktivtestSchalten(false); check(!$m->active()&&!$b->active()&&!$m->vars['NetzfahrplanV4Aktivtest']&&end($b->writes)===0,'switch off revokes session and requests zero'); + [$m,$b]=activeFixture(); + $m->attrs['NetzfahrplanV4AktivtestBeginn']=$clock-172800; + $m->attrs['NetzfahrplanV4AktivtestEnde']=$clock; + rejects(fn()=>$m->V4ManagerAktivtestSchalten(true),'active window rejects its exact expiry boundary'); + check($m->attrs['NetzfahrplanV4AktivtestBeginn']===$clock-172800 + &&$m->attrs['NetzfahrplanV4AktivtestEnde']===$clock&&!$m->active()&&!$b->active(), + 'expired active window is never renewed or armed'); + [$m,$b]=activeFixture(); + $m->attrs['NetzfahrplanV4AktivtestBeginn']=$clock-1; + $m->attrs['NetzfahrplanV4AktivtestEnde']=$clock+172800; + rejects(fn()=>$m->V4ManagerAktivtestSchalten(true),'active window rejects even one second beyond 48 hours'); + check($b->writes===[]&&!$m->active()&&!$b->active(), + 'oversized active window cannot cause register writes or arm either session'); [$m,$b]=activeFixture();$m->props['NetzfahrplanV4WatchdogVerzichtErlaubt']=false; rejects(fn()=>$m->V4ManagerAktivtestSchalten(true),'active test requires explicit watchdog waiver property'); [$m,$b]=activeFixture();$b->props['NetzfahrplanV4WatchdogVerzichtErlaubt']=false; diff --git a/tests/V4ControlTrial/register_checks.php b/tests/V4ControlTrial/register_checks.php index 92a1b78..42d5b3f 100644 --- a/tests/V4ControlTrial/register_checks.php +++ b/tests/V4ControlTrial/register_checks.php @@ -26,6 +26,7 @@ $battery->props = [ 'LeistungsRegisterVariableID' => 3, 'Aenderungssperre' => 0, ]; $method = new ReflectionMethod(Batterie::class, 'schreibeRegister'); +if (PHP_VERSION_ID < 80100) $method->setAccessible(true); $accepted = false; $writes = []; try { $method->invoke($battery, 12000, false); throw new LogicException('FALSE was accepted'); } catch (RuntimeException $error) { diff --git a/tests/V4Feedback/message_checks.php b/tests/V4Feedback/message_checks.php index 0c85b13..2cf41ac 100644 --- a/tests/V4Feedback/message_checks.php +++ b/tests/V4Feedback/message_checks.php @@ -14,6 +14,7 @@ class IPSModule { require __DIR__.'/../../Batterie/module.php'; use Belevo\EnelixEMS\Nachrichtenvertrag as N; $b=new Batterie();$read=new ReflectionMethod(Batterie::class,'baueVerbraucherdaten');$count=0; +if (PHP_VERSION_ID < 80100) $read->setAccessible(true); function ck($v,$s){global$count;if(!$v)throw new RuntimeException('FAIL '.$s);$count++;echo "PASS $s\n";} $d=$read->invoke($b,17004);ck($d['Leistungsquelle']===N::LEISTUNGSQUELLE_BERECHNET,'partition source quality independent of hidden diagnostic variables'); $b->buffers['V4FeedbackEstimated']='0';$d=$read->invoke($b,17004);ck($d['Leistungsquelle']===N::LEISTUNGSQUELLE_GEMESSEN,'ordinary physical source marked measured');