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. } } }