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