Tests / test (push) Failing after 49s
Merge the V4 development history with current manager, SDL and setpoint fixes. Retain guarded trial behavior and consolidate controls in Prognose / Forecast. Exclude the unverified accounting-evidence change; no runtime deployment or new dispatch permission. Validated: 357 PHPUnit tests / 1758 assertions, 150 PHP syntax checks, 310 backend tests, 16 portal tests and isolated UI/receiver checks. develop and beta publication explicitly approved by Daniel Haefliger.
228 lines
13 KiB
PHP
228 lines
13 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace Belevo\EnelixEMS;
|
|
|
|
use RuntimeException;
|
|
use Throwable;
|
|
|
|
require_once __DIR__ . '/NetzfahrplanV4Regeltest.php';
|
|
require_once __DIR__ . '/BatterieNetzfahrplanV4RueckmeldungTrait.php';
|
|
|
|
/** Dedicated short-lease battery input. Disabled until explicitly armed on BOTH modules. */
|
|
trait BatterieNetzfahrplanV4TestTrait
|
|
{
|
|
use BatterieNetzfahrplanV4RueckmeldungTrait;
|
|
private function registriereV4BatterieTest(): void
|
|
{
|
|
$this->registriereV4Rueckmeldung();
|
|
$this->RegisterPropertyBoolean('NetzfahrplanV4RegeltestErlaubt', false);
|
|
$this->RegisterPropertyBoolean('NetzfahrplanV4AktivtestErlaubt', false);
|
|
$this->RegisterPropertyBoolean('NetzfahrplanV4WatchdogVerzichtErlaubt', false);
|
|
$this->RegisterAttributeString('NetzfahrplanV4LetzteTestsitzung', '');
|
|
$this->RegisterPropertyString('NetzfahrplanV4GeraeteWatchdogNachweis', '');
|
|
$this->RegisterAttributeString('NetzfahrplanV4TestStatus', '{"status":"disabled"}');
|
|
$this->RegisterTimer('NetzfahrplanV4TestWatchdog', 0,
|
|
"IPS_RequestAction(\$_IPS['TARGET'], 'NetzfahrplanV4TestWatchdog', 0);");
|
|
}
|
|
|
|
private function v4BatterieTestSitzung(): array
|
|
{
|
|
$s = json_decode($this->GetBuffer('V4BatteryTrialSession') ?: '{}', true);
|
|
return is_array($s) ? $s : [];
|
|
}
|
|
|
|
private function v4BatterieTestAktiv(): bool
|
|
{
|
|
return ($this->v4BatterieTestSitzung()['active'] ?? false) === true;
|
|
}
|
|
|
|
/** Local test arming by the bound manager; never called by ordinary forecast traffic. */
|
|
public function V4BatterieTestStarten(string $json): void
|
|
{
|
|
$lock = 'ENELIX.V4.Battery.' . $this->InstanceID;
|
|
if (!IPS_SemaphoreEnter($lock, 1000)) throw new RuntimeException('Batterie beschaeftigt.');
|
|
try { $this->v4BatterieStartIntern($json); }
|
|
finally { IPS_SemaphoreLeave($lock); }
|
|
}
|
|
|
|
private function v4BatterieStartIntern(string $json): void
|
|
{
|
|
if (!$this->ReadPropertyBoolean('NetzfahrplanV4RegeltestErlaubt')) throw new RuntimeException('Batterietest lokal nicht erlaubt.');
|
|
$s = json_decode($json, true, 32, JSON_THROW_ON_ERROR);$now = time();
|
|
$activeTest = ($s['kind'] ?? null) === 'local_active_test_consent';
|
|
if (!is_array($s)
|
|
|| !in_array($s['kind'] ?? null, ['local_control_trial_consent', 'local_active_test_consent'], true)
|
|
|| ($s['batteryInstanceId'] ?? null) !== $this->InstanceID || $this->v4BatterieTestAktiv()) {
|
|
throw new RuntimeException('Batterie-Testanmeldung ungueltig oder bereits aktiv.');
|
|
}
|
|
NetzfahrplanV4Regeltest::uuid($s['sessionId'] ?? null);
|
|
if ($this->ReadAttributeString('NetzfahrplanV4LetzteTestsitzung') === $s['sessionId']) throw new RuntimeException('Testsitzung bereits beendet/verwendet.');
|
|
NetzfahrplanV4Regeltest::uuid($s['installationId'] ?? null);
|
|
$manager = NetzfahrplanV4Regeltest::integer($s['managerId'] ?? null, 'Manager', 1, 99999);
|
|
$managerPermission = $activeTest ? 'NetzfahrplanV4AktivtestErlaubt' : 'NetzfahrplanV4RegeltestErlaubt';
|
|
if (!in_array($manager, $this->zugeordneteManagerIDs(), true)
|
|
|| IPS_GetProperty($manager, $managerPermission) !== true) {
|
|
throw new RuntimeException('Manager nicht fuer Batterietest freigegeben/zugeordnet.');
|
|
}
|
|
$evidence = $this->ReadPropertyString('NetzfahrplanV4GeraeteWatchdogNachweis');
|
|
$watchdogWaived = $activeTest
|
|
&& $this->ReadPropertyBoolean('NetzfahrplanV4WatchdogVerzichtErlaubt')
|
|
&& ($s['watchdogWaived'] ?? null) === true
|
|
&& ($s['actuatorWatchdogEvidenceId'] ?? null) === 'explicit_test_plant_watchdog_waiver';
|
|
if (!$watchdogWaived && (strlen($evidence) < 8 || $evidence !== ($s['actuatorWatchdogEvidenceId'] ?? null))) {
|
|
throw new RuntimeException('Externer Geraete-Watchdog muss separat nachgewiesen sein.');
|
|
}
|
|
$maxDuration = $activeTest
|
|
? NetzfahrplanV4Regeltest::MAX_ACTIVE_TEST_SECONDS
|
|
: NetzfahrplanV4Regeltest::MAX_TEST_SECONDS;
|
|
$end = NetzfahrplanV4Regeltest::integer($s['expiresAt'] ?? null, 'Testende', $now + 1, $now + $maxDuration);
|
|
$s['maxChargeW'] = NetzfahrplanV4Regeltest::number($s['maxChargeW'] ?? null, 'Testladen', 1, 1e9);
|
|
$s['maxDischargeW'] = NetzfahrplanV4Regeltest::number($s['maxDischargeW'] ?? null, 'Testentladen', 1, 1e9);
|
|
if (!is_string($s['assetId'] ?? null) || $s['assetId'] === '') throw new RuntimeException('Batterie-ID fehlt.');
|
|
if (!$this->GetValue('Aktiv') || $this->ReadPropertyInteger('Batteriemanagement') !== 2) {
|
|
throw new RuntimeException('Batterie nicht unter ENELIX-Regelung.');
|
|
}
|
|
$feedback = $this->v4BatterieRueckmeldung();
|
|
$now = time();
|
|
if ($now >= $end) throw new RuntimeException('Testsitzung waehrend Geraeteabruf abgelaufen.');
|
|
NetzfahrplanV4Rueckmeldung::requireTrial($feedback, $s, $now);
|
|
$this->WriteAttributeString('NetzfahrplanV4LetzteTestsitzung', $s['sessionId']);
|
|
$s['active'] = true;$s['lastSequence'] = 0;$s['lastWallClockAt'] = $now;
|
|
$s['monotonicDeadline'] = hrtime(true) / 1e9 + $end - $now;
|
|
$s['commandMonotonicDeadline'] = hrtime(true) / 1e9 + 10;
|
|
$this->SetBuffer('V4BatteryTrialCommand', '{}');
|
|
$this->SetBuffer('V4BatteryTrialSession', json_encode($s, JSON_THROW_ON_ERROR));
|
|
// Old ordinary values may not resume after the test without a NEW manager message.
|
|
$this->setzeZustand('SollwertGueltig', false);
|
|
$this->SetTimerInterval('NetzfahrplanV4TestWatchdog', 1000);
|
|
$this->WriteAttributeString('NetzfahrplanV4TestStatus', '{"status":"armed_waiting_command","physicalResponseVerified":false}');
|
|
}
|
|
|
|
public function V4BatterieTestBefehl(string $json): void
|
|
{
|
|
$lock = 'ENELIX.V4.Battery.' . $this->InstanceID;
|
|
if (!IPS_SemaphoreEnter($lock, 1000)) throw new RuntimeException('Batterie beschaeftigt.');
|
|
try { $this->v4BatterieBefehlIntern($json); }
|
|
finally { IPS_SemaphoreLeave($lock); }
|
|
}
|
|
|
|
private function v4BatterieBefehlIntern(string $json): void
|
|
{
|
|
$s = $this->v4BatterieTestSitzung();$now = time();$mono = hrtime(true) / 1e9;
|
|
try {
|
|
if (!$this->ReadPropertyBoolean('NetzfahrplanV4RegeltestErlaubt')
|
|
|| (($s['kind'] ?? null) === 'local_active_test_consent'
|
|
&& !$this->ReadPropertyBoolean('NetzfahrplanV4AktivtestErlaubt'))) {
|
|
throw new RuntimeException('Batterietest gesperrt.');
|
|
}
|
|
$c = json_decode($json, true, 32, JSON_THROW_ON_ERROR);
|
|
NetzfahrplanV4Regeltest::pruefeBatteriebefehl($c, $s, $now, $mono);
|
|
$s['lastSequence'] = $c['sequence'];$s['lastWallClockAt'] = $now;
|
|
$s['commandMonotonicDeadline'] = $mono + min(10, $c['expiresAt'] - $now);
|
|
$this->SetBuffer('V4BatteryTrialCommand', json_encode($c, JSON_THROW_ON_ERROR));
|
|
$this->SetBuffer('V4BatteryTrialSession', json_encode($s, JSON_THROW_ON_ERROR));
|
|
$this->aktualisiereIntern(true);
|
|
if (!$this->v4BatterieTestAktiv() || $this->ReadAttributeString('Registerfehler') !== '') {
|
|
$state = json_decode($this->ReadAttributeString('NetzfahrplanV4TestStatus'), true);
|
|
$detail = $this->ReadAttributeString('Registerfehler') ?: ($state['reason'] ?? '');
|
|
throw new RuntimeException('Batterie konnte Testbefehl nicht sicher uebernehmen: ' . substr($detail, 0, 300));
|
|
}
|
|
$this->WriteAttributeString('NetzfahrplanV4TestStatus', json_encode([
|
|
'status' => 'controller_command_accepted', 'sessionId' => $s['sessionId'],
|
|
'sourceShadowPlanId' => $c['sourceShadowPlanId'], 'sequence' => $c['sequence'],
|
|
'commandExpiresAt' => $c['expiresAt'], 'actualRequestedW' => $this->ReadAttributeInteger('LetzterSollwert'),
|
|
'physicalResponseVerified' => false], JSON_THROW_ON_ERROR));
|
|
} catch (Throwable $e) {
|
|
$this->beendeV4BatterieTest('Befehl abgelehnt: ' . substr($e->getMessage(), 0, 500), true);
|
|
throw $e;
|
|
}
|
|
}
|
|
|
|
/** Explicit local abort or ApplyChanges: revoke before any register action. */
|
|
private function beendeV4BatterieTest(string $reason, bool $writeNow): void
|
|
{
|
|
$oldStatus = json_decode($this->ReadAttributeString('NetzfahrplanV4TestStatus'), true);
|
|
$active = $this->v4BatterieTestAktiv() || in_array($oldStatus['status'] ?? '', ['controller_command_accepted', 'armed_waiting_command', 'stop_pending', 'stop_failed'], true);
|
|
$this->SetBuffer('V4BatteryTrialSession', '{}');$this->SetBuffer('V4BatteryTrialCommand', '{}');
|
|
$this->SetTimerInterval('NetzfahrplanV4TestWatchdog', 0);
|
|
if (!$active) return;
|
|
$this->setzeZustand('SollwertGueltig', false);$this->setzeZustand('Sollleistung', 0);
|
|
$report = ['status' => 'stop_pending', 'reason' => $reason, 'physicalResponseVerified' => false];
|
|
$this->WriteAttributeString('NetzfahrplanV4TestStatus', json_encode($report, JSON_THROW_ON_ERROR));
|
|
if ($writeNow) {
|
|
try { $this->schreibeRegister(0, true);$report['status'] = 'stopped'; }
|
|
catch (Throwable $e) {
|
|
$report['status'] = 'stop_failed';
|
|
$this->WriteAttributeString('NetzfahrplanV4TestStatus', json_encode($report, JSON_THROW_ON_ERROR));
|
|
throw $e;
|
|
}
|
|
$this->WriteAttributeString('NetzfahrplanV4TestStatus', json_encode($report, JSON_THROW_ON_ERROR));
|
|
}
|
|
}
|
|
|
|
public function V4BatterieTestStoppen(): void
|
|
{
|
|
$lock = 'ENELIX.V4.Battery.' . $this->InstanceID;
|
|
if (!IPS_SemaphoreEnter($lock, 1000)) throw new RuntimeException('Batterie beschaeftigt.');
|
|
try { $this->beendeV4BatterieTest('Lokaler Testabbruch', true); }
|
|
finally { IPS_SemaphoreLeave($lock); }
|
|
}
|
|
|
|
public function GetV4BatterieTestStatus(): string
|
|
{
|
|
return $this->ReadAttributeString('NetzfahrplanV4TestStatus');
|
|
}
|
|
|
|
/** Called inside the existing battery update, immediately before writing registers. */
|
|
private function v4BatterieTestZiel(array $measurements, bool $available, int $now): ?int
|
|
{
|
|
if (!$this->v4BatterieTestAktiv()) return null;
|
|
$s = $this->v4BatterieTestSitzung();
|
|
try {
|
|
if (!$this->ReadPropertyBoolean('NetzfahrplanV4RegeltestErlaubt')
|
|
|| (($s['kind'] ?? null) === 'local_active_test_consent'
|
|
&& !$this->ReadPropertyBoolean('NetzfahrplanV4AktivtestErlaubt'))) {
|
|
throw new RuntimeException('Lokale Testfreigabe entzogen.');
|
|
}
|
|
$feedback = $this->v4BatterieRueckmeldung();
|
|
// The confirmed device read may cross a wall-clock second. All freshness,
|
|
// watchdog and switching decisions below use the post-read clocks.
|
|
$now = time();$mono = hrtime(true) / 1e9;
|
|
NetzfahrplanV4Rueckmeldung::requireTrial($feedback, $s, $now);
|
|
$c = json_decode($this->GetBuffer('V4BatteryTrialCommand') ?: '{}', true);
|
|
if ($now >= $s['expiresAt'] || $mono >= $s['monotonicDeadline'] || $now < $s['lastWallClockAt']) throw new RuntimeException('Testsitzung abgelaufen/Zeitsprung.');
|
|
if (!$c) {
|
|
if ($mono >= $s['commandMonotonicDeadline']) throw new RuntimeException('Kein Startbefehl innerhalb Watchdog.');
|
|
return 0;
|
|
}
|
|
$w = NetzfahrplanV4Regeltest::batterieZiel($c, $s, $measurements, [
|
|
'available' => $available, 'reserve' => $this->ReadPropertyFloat('ReserveLadezustand'),
|
|
'minimum' => $this->ReadPropertyFloat('MindestLadezustand'),
|
|
'blocked' => (bool) $this->leseZustand('HystereseAktiv'),
|
|
'rechargeLimitW' => $this->ReadPropertyBoolean('NachladenMitMaximalleistung') ? null : $this->ReadPropertyInteger('MaximaleNachladeleistung'),
|
|
], $now, $mono);
|
|
// Respect switching lock for increases/reversals; safety reductions and zero are immediate.
|
|
$last = $this->ReadAttributeInteger('LetzterSollwert');
|
|
if ($now < $this->ReadAttributeInteger('AenderungFreigabeAb') && $w !== $last) {
|
|
if ($last * $w < 0) $w = 0;
|
|
elseif (abs($w) > abs($last)) $w = $last;
|
|
}
|
|
// Final hardware-policy/ramp clipping must not invalidate the binding or grid limits.
|
|
if (($c['feedbackConfigHash'] ?? null) !== $feedback['configHash']) throw new RuntimeException('feedback_configuration_changed');
|
|
$expectedGrid = $feedback['gridW'] - $feedback['batteryW'] + $w;
|
|
if (($c['importLimitW'] !== null && $expectedGrid > $c['importLimitW'] + 1)
|
|
|| ($c['exportLimitW'] !== null && -$expectedGrid > $c['exportLimitW'] + 1)) {
|
|
throw new RuntimeException('final_battery_clamp_cannot_meet_grid_limit');
|
|
}
|
|
$s['lastWallClockAt'] = $now;
|
|
$this->SetBuffer('V4BatteryTrialSession', json_encode($s, JSON_THROW_ON_ERROR));
|
|
return $w;
|
|
} catch (Throwable $e) {
|
|
$this->beendeV4BatterieTest($e->getMessage(), false);
|
|
return 0; // revoke stale value even if ordinary cached setpoint existed
|
|
}
|
|
}
|
|
}
|