feat(manager): add gated short-lease V4 control trial and local fallback
This commit is contained in:
@@ -0,0 +1,177 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Belevo\EnelixEMS;
|
||||
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
|
||||
require_once __DIR__ . '/NetzfahrplanV4Regeltest.php';
|
||||
|
||||
/** Local consent + server commissioning authority. NEVER executes a preview alone. */
|
||||
trait ManagerNetzfahrplanV4TestTrait
|
||||
{
|
||||
private function registriereV4ManagerTest(): void
|
||||
{
|
||||
$this->RegisterPropertyBoolean('NetzfahrplanV4RegeltestErlaubt', false);
|
||||
$this->RegisterAttributeString('NetzfahrplanV4LetzteTestsitzung', '');
|
||||
$this->RegisterAttributeString('NetzfahrplanV4RegeltestStatus', '{"status":"disabled"}');
|
||||
$this->RegisterTimer('NetzfahrplanV4TestTick', 0,
|
||||
"IPS_RequestAction(\$_IPS['TARGET'], 'Regeln', true);");
|
||||
}
|
||||
|
||||
private function v4ManagerTestSession(): array
|
||||
{
|
||||
$value = json_decode($this->GetBuffer('V4ManagerTrialSession') ?: '{}', true);
|
||||
return is_array($value) ? $value : [];
|
||||
}
|
||||
|
||||
private function v4ManagerTestBedingungen(): void
|
||||
{
|
||||
if (IPS_GetInstance($this->InstanceID)['InstanceStatus'] !== 102
|
||||
|| !$this->ReadPropertyBoolean('NetzfahrplanV4RegeltestErlaubt')
|
||||
|| !$this->ReadPropertyBoolean('NetzfahrplanV4SchattenAktiv')
|
||||
|| !$this->ReadPropertyBoolean('NetzfahrplanV4EmpfangAktiv')
|
||||
|| $this->ReadPropertyBoolean('NetzfahrplanAktiv') || !$this->GetValue('Aktiv')
|
||||
|| !$this->berechtigungLizenziert(Lizenzpruefung::NETZFAHRPLAN)) {
|
||||
throw new RuntimeException('Keine lokale Testberechtigung oder konkurrierender Fahrplan.');
|
||||
}
|
||||
}
|
||||
|
||||
/** Explicit kernel-local user action only. No network request can call this via the portal. */
|
||||
public function V4ManagerTestStarten(string $json): void
|
||||
{
|
||||
$lock = 'ENELIX.V4.Control.' . $this->InstanceID;
|
||||
if (!IPS_SemaphoreEnter($lock, 1000)) throw new RuntimeException('Regelung beschaeftigt.');
|
||||
$armedBattery = null;
|
||||
try {
|
||||
$this->v4ManagerTestBedingungen();
|
||||
if ($this->v4ManagerTestSession()) throw new RuntimeException('Testsitzung bereits aktiv; nicht verlaengern.');
|
||||
$request = json_decode($json, true, 16, JSON_THROW_ON_ERROR);
|
||||
if (!is_array($request) || ($request['confirmation'] ?? null) !== 'START_BOUNDED_V4_CONTROL_TRIAL') {
|
||||
throw new RuntimeException('Ausdrueckliche lokale Testbestaetigung erforderlich.');
|
||||
}
|
||||
$session = NetzfahrplanV4Regeltest::uuid($request['sessionId'] ?? null);
|
||||
if ($this->ReadAttributeString('NetzfahrplanV4LetzteTestsitzung') === $session) throw new RuntimeException('Testsitzung bereits benutzt; neue Freigabe erforderlich.');
|
||||
$envelope = json_decode($this->ReadAttributeString('NetzfahrplanV4Empfang'), true, 64, JSON_THROW_ON_ERROR);
|
||||
$a = $envelope['response']['controlledTrial'] ?? null;
|
||||
if (!is_array($a) || ($a['sessionId'] ?? null) !== $session) throw new RuntimeException('Keine passende separate Serverfreigabe empfangen.');
|
||||
[$op, $actual] = $this->netzfahrplanV4LokaleVorschauwerte();
|
||||
$batteryID = (int) ($a['batteryInstanceId'] ?? 0);
|
||||
if (!in_array($batteryID, $this->aktiveVerbraucherIDs(), true)
|
||||
|| !IPS_InstanceExists($batteryID)
|
||||
|| IPS_GetInstance($batteryID)['ModuleInfo']['ModuleID'] !== '{437FB683-517F-4FEC-8CCB-FE6B0A62B69E}') {
|
||||
throw new RuntimeException('Testbatterie ist keine aktive zugeordnete Batterieinstanz.');
|
||||
}
|
||||
$assets = json_decode($this->ReadPropertyString('AnlagenBatterien'), true, 32, JSON_THROW_ON_ERROR);
|
||||
if (count($assets) !== 1 || $assets[0]['ID'] !== ($a['assetId'] ?? null)
|
||||
|| $assets[0]['SOCVariableID'] !== IPS_GetProperty($batteryID, 'LadezustandVariableID')
|
||||
|| $assets[0]['LeistungVariableID'] !== IPS_GetProperty($batteryID, 'IstleistungVariableID')) {
|
||||
throw new RuntimeException('Physische Batteriezuordnung ist nicht eindeutig.');
|
||||
}
|
||||
$now = time();$until = min(NetzfahrplanV4Planpruefung::zeit($a['expiresAt']),
|
||||
$now + NetzfahrplanV4Regeltest::integer($request['durationSeconds'] ?? null, 'Lokale Testdauer', 30, 1800));
|
||||
$s = ['kind' => 'local_control_trial_consent', 'enabled' => true, 'legacyEnabled' => false,
|
||||
'sessionId' => $session, 'installationId' => $this->ReadAttributeString('LizenzInstallationID'),
|
||||
'managerId' => $this->InstanceID, 'batteryInstanceId' => $batteryID, 'assetId' => $a['assetId'],
|
||||
'expiresAt' => $until, 'maxChargeW' => NetzfahrplanV4Regeltest::number($request['maxChargeW'] ?? null, 'Testladegrenze', 1, 5000),
|
||||
'maxDischargeW' => NetzfahrplanV4Regeltest::number($request['maxDischargeW'] ?? null, 'Testentladegrenze', 1, 5000),
|
||||
'actuatorWatchdogEvidenceId' => IPS_GetProperty($batteryID, 'NetzfahrplanV4GeraeteWatchdogNachweis'),
|
||||
'sequence' => 1, 'lastWallClockAt' => $now,
|
||||
'monotonicDeadline' => hrtime(true) / 1e9 + $until - $now];
|
||||
// Validate everything BEFORE arming either module. This rejects all normal shadow replies.
|
||||
NetzfahrplanV4Regeltest::befehl($envelope['response'], $op, $actual, $s, $now, $envelope['receivedAt']);
|
||||
$this->WriteAttributeString('NetzfahrplanV4LetzteTestsitzung', $session);
|
||||
IPS_RequestAction($batteryID, 'NetzfahrplanV4TestStart', json_encode($s, JSON_THROW_ON_ERROR));
|
||||
$armedBattery = $batteryID;
|
||||
$this->SetBuffer('V4ManagerTrialSession', json_encode($s, JSON_THROW_ON_ERROR));
|
||||
$this->SetTimerInterval('NetzfahrplanV4TestTick', 2000);
|
||||
$this->WriteAttributeString('NetzfahrplanV4RegeltestStatus', json_encode([
|
||||
'status' => 'armed_local_trial', 'sessionId' => $session, 'expiresAt' => $until,
|
||||
'physicalResponseVerified' => false], JSON_THROW_ON_ERROR));
|
||||
} catch (Throwable $e) {
|
||||
$this->SetBuffer('V4ManagerTrialSession', '{}');$this->SetTimerInterval('NetzfahrplanV4TestTick', 0);
|
||||
if ($armedBattery !== null) IPS_RequestAction($armedBattery, 'NetzfahrplanV4TestStop', 0);
|
||||
throw $e;
|
||||
} finally { IPS_SemaphoreLeave($lock); }
|
||||
}
|
||||
|
||||
/** Revocation precedes I/O; if the device call fails its own ten-second lease still expires. */
|
||||
private function v4ManagerTestAbbrechen(string $reason): void
|
||||
{
|
||||
$s = $this->v4ManagerTestSession();
|
||||
$this->SetBuffer('V4ManagerTrialSession', '{}');$this->SetTimerInterval('NetzfahrplanV4TestTick', 0);
|
||||
if (!$s) return;
|
||||
$result = ['status' => 'stopped_fallback_pending', 'reason' => $reason,
|
||||
'sessionId' => $s['sessionId'], 'physicalResponseVerified' => false];
|
||||
// Reissue the next ordinary target even if numerically equal to a previously cached value.
|
||||
$this->WriteAttributeString('LetzteSollwerte', '{}');
|
||||
try {
|
||||
IPS_RequestAction($s['batteryInstanceId'], 'NetzfahrplanV4TestStop', 0);
|
||||
$result['controllerStopAccepted'] = true;
|
||||
} catch (Throwable $e) {
|
||||
$result['controllerStopAccepted'] = false;$result['reason'] .= '; Geraete-Abbruch fehlgeschlagen, Watchdog pruefen.';
|
||||
}
|
||||
$this->WriteAttributeString('NetzfahrplanV4RegeltestStatus', json_encode($result, JSON_THROW_ON_ERROR));
|
||||
}
|
||||
|
||||
public function V4ManagerTestStoppen(): void
|
||||
{
|
||||
$lock = 'ENELIX.V4.Control.' . $this->InstanceID;
|
||||
if (!IPS_SemaphoreEnter($lock, 1000)) throw new RuntimeException('Regelung beschaeftigt; Stop erneut anfordern.');
|
||||
try { $this->v4ManagerTestAbbrechen('Lokaler Testabbruch'); }
|
||||
finally { IPS_SemaphoreLeave($lock); }
|
||||
$this->regeln(true); // fresh ordinary command; never restore a cached pre-trial target
|
||||
}
|
||||
|
||||
public function GetV4ManagerTestStatus(): string
|
||||
{
|
||||
return $this->ReadAttributeString('NetzfahrplanV4RegeltestStatus');
|
||||
}
|
||||
|
||||
/** Called under the existing regulator's serialization, after allocation but before writes. */
|
||||
private function v4ManagerTestUebergabe(array &$allocation, array $consumers, bool $synchronized): void
|
||||
{
|
||||
$s = $this->v4ManagerTestSession();
|
||||
if (!$s) return;
|
||||
try {
|
||||
$this->v4ManagerTestBedingungen();$now = time();
|
||||
if (!$synchronized || $now >= $s['expiresAt'] || hrtime(true) / 1e9 >= $s['monotonicDeadline']) {
|
||||
throw new RuntimeException('Testende oder Verbraucher nicht synchronisiert.');
|
||||
}
|
||||
[$op, $actual] = $this->netzfahrplanV4LokaleVorschauwerte();
|
||||
$envelope = json_decode($this->ReadAttributeString('NetzfahrplanV4Empfang'), true, 64, JSON_THROW_ON_ERROR);
|
||||
// Other consumers stay under the existing controller. Compensate their predicted
|
||||
// change once; no double-counting of the battery's already flowing actual power.
|
||||
$otherDelta = 0.0;$found = false;
|
||||
foreach ($consumers as $consumer) {
|
||||
$id = $consumer['InstanzID'];
|
||||
if ($id === $s['batteryInstanceId']) { $found = true;continue; }
|
||||
if (!array_key_exists($id, $allocation['Sollwerte'])) continue;
|
||||
$d = $consumer['Daten'];
|
||||
if (($d['Leistungsquelle'] ?? null) !== Nachrichtenvertrag::LEISTUNGSQUELLE_GEMESSEN) throw new RuntimeException('Andere Verbraucherleistung nicht gemessen.');
|
||||
$otherDelta += NetzfahrplanV4Regeltest::number($allocation['Sollwerte'][$id], 'Anderer Sollwert', -1e9, 1e9)
|
||||
- NetzfahrplanV4Regeltest::number($d['Istleistung_W'] ?? null, 'Andere Istleistung', -1e9, 1e9);
|
||||
}
|
||||
if (!$found) throw new RuntimeException('Testbatterie nicht mehr in aktiver Zuordnung.');
|
||||
$op['gridW'] += $otherDelta;
|
||||
if (!is_array($envelope['response'] ?? null) || !is_int($envelope['receivedAt'] ?? null)) {
|
||||
throw new RuntimeException('Kein gueltiger empfangener Testkontext.');
|
||||
}
|
||||
$command = NetzfahrplanV4Regeltest::befehl($envelope['response'], $op, $actual, $s, $now, $envelope['receivedAt']);
|
||||
// A controller-side refusal stops the session; never downgrade it to an ordinary target.
|
||||
IPS_RequestAction($s['batteryInstanceId'], 'NetzfahrplanV4TestCommand', json_encode($command, JSON_THROW_ON_ERROR));
|
||||
$s['sequence']++;$s['lastWallClockAt'] = $now;
|
||||
$this->SetBuffer('V4ManagerTrialSession', json_encode($s, JSON_THROW_ON_ERROR));
|
||||
unset($allocation['Sollwerte'][$s['batteryInstanceId']]); // one owner of this actuator
|
||||
$this->WriteAttributeString('NetzfahrplanV4RegeltestStatus', json_encode([
|
||||
'status' => 'controller_command_sent', 'sessionId' => $s['sessionId'],
|
||||
'sourceShadowPlanId' => $command['sourceShadowPlanId'], 'command' => $command,
|
||||
'otherConsumerPredictedDeltaW' => $otherDelta, 'physicalResponseVerified' => false], JSON_THROW_ON_ERROR));
|
||||
} catch (Throwable $e) {
|
||||
$this->v4ManagerTestAbbrechen($e->getMessage());
|
||||
// Stop writes zero and next NEW ordinary allocation runs on this same pass.
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user