From d1fc62d43c3012281d032d70989c7f45c0e5162a Mon Sep 17 00:00:00 2001 From: dh_Agent Date: Tue, 29 Sep 2026 08:46:44 +0000 Subject: [PATCH 1/6] fix(manager): Regelung bei veralteten Verbrauchern fortsetzen --- CHANGELOG.md | 2 ++ Manager/module.php | 37 ++++++++++++-------------- README.md | 14 ++++++---- docs/Schnittstelle.md | 7 +++-- tests/ManagerModulstrukturTest.php | 26 ++++++++++++++++++ tests/ManagerReglerTest.php | 28 ++++++++++++++++++++ tests/Symcon/modules/Manager.php | 42 ++++++++++++++++++++++++++++++ 7 files changed, 129 insertions(+), 27 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8c9419b..563cb15 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,8 @@ Alle wesentlichen Aenderungen an Enelix EMS werden in dieser Datei dokumentiert. - Stufenlose Leistungsbereiche fuer Batteriespeicher. - Ladefreigabe neuer Ladestationsinstanzen standardmaessig aktiviert. - Easee-Ladestationen laden einen fehlenden Initialstatus ueber die Observations-API nach und starten bei noch unbekannter Ausgangsphase konservativ einphasig. +- Fehlende oder veraltete Verbraucher blockieren die Verteilung an aktuelle Verbraucher nicht mehr. +- Die Stoerungsueberwachung fordert einen fehlenden Geraetezugang innerhalb ihres Retry-Zyklus automatisch neu an. - Vollstaendige PHP-, Struktur- und Symcon-Modultests fuer alle Module. ### Hinweise diff --git a/Manager/module.php b/Manager/module.php index b4cd7a1..7fa93bb 100644 --- a/Manager/module.php +++ b/Manager/module.php @@ -689,22 +689,6 @@ class Manager extends IPSModule implements ManagerSchnittstelle ); $this->setzeDiagnosewert('WirksameLastspitzengrenze', $lastspitzengrenze ?? 0.0); $this->regleEinspeisung($netzleistung, $stoerungen, $erneuern); - if (!$synchronisiert) { - $this->setzeDiagnosewert('Verteilbudget', 0.0); - $this->setzeDiagnosewert('Verbraucherstatus', json_encode([ - 'Betriebsart' => $betriebsart, - 'Synchronisiert' => false, - 'Verbraucher' => $verbraucher, - 'NichtGeregelt' => $nichtGeregelt, - ], JSON_THROW_ON_ERROR)); - $this->setzeDiagnosewert( - 'NichtGeregelteGeraete', - $this->nichtGeregelteGeraeteText($nichtGeregelt) - ); - $this->aktualisiereStoerungen($verbraucher, $stoerungen); - $this->SetStatus(self::STATUS_AKTIV); - return; - } $fahrplanVorgabe = $this->aktuelleNetzfahrplanVorgabe(); $zielnetzleistung = $betriebsart === ManagerRegler::BETRIEBSART_PEAK @@ -719,14 +703,16 @@ class Manager extends IPSModule implements ManagerSchnittstelle $this->setzeDiagnosewert('Verteilbudget', (float) $ergebnis['Verteilbudget_W']); $this->setzeDiagnosewert('Verbraucherstatus', json_encode([ 'Betriebsart' => $betriebsart, - 'Synchronisiert' => true, + 'Synchronisiert' => $synchronisiert, 'Restabweichung_W' => $ergebnis['Restabweichung_W'], 'Verbraucher' => $verbraucher, - 'NichtGeregelt' => [], + 'NichtGeregelt' => $nichtGeregelt, ], JSON_THROW_ON_ERROR)); $this->setzeDiagnosewert( 'NichtGeregelteGeraete', - 'Alle aktiven Verbraucher werden geregelt.' + $nichtGeregelt === [] + ? 'Alle aktiven Verbraucher werden geregelt.' + : $this->nichtGeregelteGeraeteText($nichtGeregelt) ); $letzteSollwerte = $this->leseJsonAttribut('LetzteSollwerte'); @@ -1450,7 +1436,18 @@ class Manager extends IPSModule implements ManagerSchnittstelle $token = trim($this->ReadAttributeString('PrognoseInstallationsToken')); if ($token === '') { - throw new RuntimeException('Der Geraetezugang fuer die Stoerueberwachung fehlt.'); + $this->aktualisiereLizenzfreigabe( + $this->ReadPropertyString('Lizenzcode'), + true + ); + $token = trim($this->ReadAttributeString('PrognoseInstallationsToken')); + } + if ($token === '') { + $lizenzfehler = trim($this->ReadAttributeString('LizenzLetzterFehler')); + throw new RuntimeException( + 'Der Geraetezugang fuer die Stoerueberwachung fehlt.' + . ($lizenzfehler !== '' ? ' Lizenzpruefung: ' . $lizenzfehler : '') + ); } $snapshot['capturedAt'] = gmdate('Y-m-d\TH:i:s\Z', $jetzt); $this->uebertrageStoerungsSnapshot( diff --git a/README.md b/README.md index 3646ade..de1fe02 100644 --- a/README.md +++ b/README.md @@ -43,12 +43,16 @@ Ein Betriebsartwechsel wird synchronisiert: 3. `null` ist nur eine Ankuendigung und kein Schaltbefehl. 4. Jeder Verbraucher berechnet sein zustandsabhaengiges Leistungsangebot neu und meldet es mit derselben Betriebsart zurueck. -5. Erst wenn alle aktiven Verbraucher synchronisiert sind, verteilt der - Manager konkrete Sollleistungen. +5. Der Manager verteilt konkrete Sollleistungen an alle bereits + synchronisierten Verbraucher. +6. Fehlende, veraltete oder noch nicht umgeschaltete Verbraucher bleiben von + der Verteilung ausgeschlossen und werden als Stoerung ausgewiesen. -Der Manager verwendet damit nie ein PV-Angebot fuer Peak oder umgekehrt. Eine -technische Schaltsperre, eine ausstehende Rueckmeldung oder eine Mindestzeit -kann das Angebot weiterhin auf die aktuell gehaltene Leistung begrenzen. +Der Manager verwendet damit nie ein PV-Angebot fuer Peak oder umgekehrt. Ein +nicht antwortender Verbraucher blockiert die Regelung der uebrigen Anlage +nicht; sein aktueller Einfluss ist bereits in der Netzleistungsmessung +enthalten. Eine technische Schaltsperre oder eine Mindestzeit kann das Angebot +weiterhin auf die aktuell gehaltene Leistung begrenzen. | Verbraucher | PV-Angebot | Peak-Angebot | | --- | --- | --- | diff --git a/docs/Schnittstelle.md b/docs/Schnittstelle.md index f959724..4eec288 100644 --- a/docs/Schnittstelle.md +++ b/docs/Schnittstelle.md @@ -78,8 +78,11 @@ zulaessig ist. `Sollleistung_W=null`. 4. Jeder Verbraucher berechnet und meldet seine PowerSteps fuer diese Betriebsart. -5. Erst wenn alle aktiven Verbraucher synchronisiert sind, verteilt der Manager - Sollleistungen. +5. Der Manager verteilt Sollleistungen an alle bereits synchronisierten + Verbraucher. +6. Fehlende, veraltete oder noch nicht umgeschaltete Verbraucher werden nicht + angesteuert und als Stoerung ausgewiesen; sie blockieren die aktuellen + Verbraucher nicht. Damit kann jeder Verbrauchertyp unterschiedliche Angebote fuer PV und Peak melden, ohne dass der Manager seine interne Geraetelogik kennen muss. diff --git a/tests/ManagerModulstrukturTest.php b/tests/ManagerModulstrukturTest.php index 1356df1..0b20388 100644 --- a/tests/ManagerModulstrukturTest.php +++ b/tests/ManagerModulstrukturTest.php @@ -254,6 +254,25 @@ final class ManagerModulstrukturTest extends TestCase ); } + public function testManagerRegeltAktuelleVerbraucherTrotzFehlenderRueckmeldungen(): void + { + $inhalt = (string) file_get_contents(__DIR__ . '/../Manager/module.php'); + + self::assertStringNotContainsString("if (!\$synchronisiert) {", $inhalt); + self::assertStringContainsString( + "'Synchronisiert' => \$synchronisiert", + $inhalt + ); + self::assertStringContainsString( + "'NichtGeregelt' => \$nichtGeregelt", + $inhalt + ); + self::assertStringContainsString( + "\$ergebnis = ManagerRegler::verteile(", + $inhalt + ); + } + public function testManagerUebertraegtStoerungsvollsnapshotsMitRetry(): void { $inhalt = (string) file_get_contents(__DIR__ . '/../Manager/module.php'); @@ -280,6 +299,13 @@ final class ManagerModulstrukturTest extends TestCase '"name":"StoermeldeAnschluss"', str_replace(' ', '', (string) file_get_contents(__DIR__ . '/../Manager/form.json')) ); + self::assertStringContainsString( + "if (\$token === '') {\n" + . " \$this->aktualisiereLizenzfreigabe(\n" + . " \$this->ReadPropertyString('Lizenzcode'),\n" + . " true", + $inhalt + ); self::assertGreaterThanOrEqual( 3, substr_count( diff --git a/tests/ManagerReglerTest.php b/tests/ManagerReglerTest.php index a5d1b48..31c840d 100644 --- a/tests/ManagerReglerTest.php +++ b/tests/ManagerReglerTest.php @@ -125,6 +125,34 @@ final class ManagerReglerTest extends TestCase self::assertSame(0, $ergebnis['Restabweichung_W']); } + public function testVeralteterVerbraucherBlockiertUeberschussverteilungNicht(): void + { + $ergebnis = ManagerRegler::verteile( + -2411.0, + 0.0, + [ + $this->verbraucher( + 44234, + 0, + [['Von_W' => -39000, 'Bis_W' => 39000]], + 0 + ), + $this->verbraucher(38676, 1, [0, 4600], 0), + $this->verbraucher(29743, 1, [0, 6500], 0), + $this->verbraucher(43878, 2, [13850], 10151, false), + $this->verbraucher(46052, 2, [11000], 0, false), + ], + 'PrioritaetPV' + ); + + self::assertSame(12562, $ergebnis['Verteilbudget_W']); + self::assertSame( + [44234 => 2411, 29743 => 0, 38676 => 0], + $ergebnis['Sollwerte'] + ); + self::assertSame(0, $ergebnis['Restabweichung_W']); + } + public function testFehlendeIstleistungFaelltAufAngenommenenSollwertZurueck(): void { $verbraucher = $this->verbraucher(10, 0, [0, 3000], null, false); diff --git a/tests/Symcon/modules/Manager.php b/tests/Symcon/modules/Manager.php index e4b0e6a..ae6d5aa 100644 --- a/tests/Symcon/modules/Manager.php +++ b/tests/Symcon/modules/Manager.php @@ -444,4 +444,46 @@ return static function (TestContext $test): void { $test->objectByIdent('Sammelstoerung', $managerId) )); }); + + $test->runCase('Veralteter Verbraucher blockiert aktuelle Verbraucher nicht', static function ( + TestContext $test + ) use ( + $managerId, + $netzleistungId, + $einStufigId, + $warmwasserId, + $pufferspeicherId, + $melden, + $verbraucherstatus + ): void { + IPS_SetProperty($managerId, 'VerbraucherTimeout', 1); + IPS_ApplyChanges($managerId); + SetValue($netzleistungId, -2400.0); + $melden(); + IPS_RequestAction($managerId, 'Regeln', true); + $test->waitUntil( + static fn (): bool => ($verbraucherstatus()['Synchronisiert'] ?? false) === true + ); + + IPS_Sleep(1100); + IPS_RequestAction($warmwasserId, 'Melden', true); + IPS_RequestAction($pufferspeicherId, 'Melden', true); + IPS_RequestAction($managerId, 'Regeln', true); + + $daten = $verbraucherstatus(); + $test->assertSame(false, $daten['Synchronisiert'] ?? null); + $test->assertSame(2, count($daten['Verbraucher'] ?? [])); + $test->assertSame(1, count($daten['NichtGeregelt'] ?? [])); + $test->assertSame( + $einStufigId, + $daten['NichtGeregelt'][0]['InstanzID'] ?? null + ); + $test->assertSame( + 'Meldung ist veraltet', + $daten['NichtGeregelt'][0]['Grund'] ?? null + ); + $test->assertTrue( + (float) GetValue($test->objectByIdent('Verteilbudget', $managerId)) > 0.0 + ); + }); }; From 3cabf597ac81d900826c79c533730357cb804638 Mon Sep 17 00:00:00 2001 From: dh_Agent Date: Tue, 29 Sep 2026 09:07:44 +0000 Subject: [PATCH 2/6] fix(ladestation): Solarpause nicht als Ladeende erkennen --- LadestationStandAlone/module.php | 4 ++- libs/LadestationRegler.php | 11 +++++-- tests/LadestationReglerTest.php | 22 +++++++++++++ .../Symcon/modules/LadestationStandAlone.php | 31 +++++++++++++++++++ 4 files changed, 64 insertions(+), 4 deletions(-) diff --git a/LadestationStandAlone/module.php b/LadestationStandAlone/module.php index c6665e3..2b182c2 100644 --- a/LadestationStandAlone/module.php +++ b/LadestationStandAlone/module.php @@ -301,7 +301,9 @@ class LadestationStandAlone extends IPSModule implements VerbraucherSchnittstell ); $status = LadestationRegler::normalisiereStatus( $rohstatus, - $this->ReadAttributeBoolean('ZustandFahrzeugVerbunden') + $this->ReadAttributeBoolean('ZustandFahrzeugVerbunden'), + $this->ReadAttributeBoolean('ZustandFahrzeugGeladen'), + $this->ReadAttributeInteger('LetzterGesetzterStrom') >= 6 ); foreach ($status as $ident => $wert) { diff --git a/libs/LadestationRegler.php b/libs/LadestationRegler.php index 55831b6..1f6df48 100644 --- a/libs/LadestationRegler.php +++ b/libs/LadestationRegler.php @@ -34,8 +34,12 @@ final class LadestationRegler * @param array{Rohstatus: int, Istleistung_W: float} $rohstatus * @return array */ - public static function normalisiereStatus(array $rohstatus, bool $warVerbunden): array - { + public static function normalisiereStatus( + array $rohstatus, + bool $warVerbunden, + bool $warGeladen = false, + bool $ladungErwartet = true + ): array { $verbunden = $rohstatus['Rohstatus'] !== 1; $leistung = max(0.0, $rohstatus['Istleistung_W']); $phasen = self::PHASEN_UNBEKANNT; @@ -47,7 +51,8 @@ final class LadestationRegler $nenner = $phasen === self::PHASEN_EIN ? 230.0 : 1.71 * 400.0; $ladestrom = $leistung / $nenner; $ermittelterMaximalstrom = 2.5 + $ladestrom; - $geladen = $warVerbunden && $ermittelterMaximalstrom < 6.0; + $geladen = $warGeladen + || ($warVerbunden && $ladungErwartet && $ermittelterMaximalstrom < 6.0); } if (!$verbunden) { diff --git a/tests/LadestationReglerTest.php b/tests/LadestationReglerTest.php index b3e09fd..c210246 100644 --- a/tests/LadestationReglerTest.php +++ b/tests/LadestationReglerTest.php @@ -44,6 +44,28 @@ final class LadestationReglerTest extends TestCase self::assertSame(4, $status['Fahrzeugstatus']); } + public function testAbsichtlichGestoppteLadungWirdNichtAlsGeladenErkannt(): void + { + $status = LadestationRegler::normalisiereStatus([ + 'Rohstatus' => 2, + 'Istleistung_W' => 0.0, + ], true, false, false); + + self::assertFalse($status['FahrzeugGeladen']); + self::assertSame(2, $status['Fahrzeugstatus']); + } + + public function testErkanntesLadeendeBleibtBisZumAbsteckenErhalten(): void + { + $status = LadestationRegler::normalisiereStatus([ + 'Rohstatus' => 2, + 'Istleistung_W' => 0.0, + ], true, true, false); + + self::assertTrue($status['FahrzeugGeladen']); + self::assertSame(4, $status['Fahrzeugstatus']); + } + public function testBetriebsartUndSolarladenBestimmenDasLeistungsangebot(): void { $solarPV = LadestationRegler::leistungsangebot(true, true, true, false, 1, 16, true, false); diff --git a/tests/Symcon/modules/LadestationStandAlone.php b/tests/Symcon/modules/LadestationStandAlone.php index 08c0b63..9e84535 100644 --- a/tests/Symcon/modules/LadestationStandAlone.php +++ b/tests/Symcon/modules/LadestationStandAlone.php @@ -61,6 +61,22 @@ return static function (TestContext $test): void { ); } + $solarPause = $test->createInstance( + '{0D94913C-0F31-4C29-A685-6EB4AE58E55D}', + 'Ladestation Solarpause', + [ + 'Geraetetyp' => 1, + 'Geraeteadresse' => '192.0.2.12', + 'MaximalerLadestrom' => 16, + 'Ladefreigabe' => true, + 'Solarladen' => true, + 'EinstellungenInVisu' => true, + 'DiagnosevariablenAnzeigen' => true, + 'Testmodus' => true, + 'Testantwort' => $goEAntwort(2, 0), + ] + ); + $managerId = $test->createInstance( '{6F771B18-59D4-4C8A-B951-3B2FE9F6A2C4}', 'Hilfsmanager Ladestation', @@ -120,6 +136,21 @@ return static function (TestContext $test): void { } }); + $test->runCase('Solarpause wird nicht als Ladeende erkannt', static function ( + TestContext $test + ) use ($solarPause): void { + IPS_RequestAction($solarPause, 'Solarladen', true); + + $test->assertSame( + false, + GetValue($test->objectByIdent('FahrzeugGeladen', $solarPause)) + ); + $test->assertSame( + 2, + GetValue($test->objectByIdent('Fahrzeugstatus', $solarPause)) + ); + }); + $test->runCase('Solar- und Peakbetrieb liefern die Enelix-1-Angebote', static function ( TestContext $test ) use ($stationen, $anforderungSenden): void { From 156591a91eb611f94194657e45a7c8254a9356ea Mon Sep 17 00:00:00 2001 From: dh_Agent Date: Tue, 29 Sep 2026 09:19:39 +0000 Subject: [PATCH 3/6] =?UTF-8?q?fix(ladestation):=20Ladeende=20nach=20Updat?= =?UTF-8?q?e=20neu=20pr=C3=BCfen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- LadestationStandAlone/module.php | 4 ++++ .../Symcon/modules/LadestationStandAlone.php | 23 +++++++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/LadestationStandAlone/module.php b/LadestationStandAlone/module.php index 2b182c2..de4abeb 100644 --- a/LadestationStandAlone/module.php +++ b/LadestationStandAlone/module.php @@ -117,6 +117,10 @@ class LadestationStandAlone extends IPSModule implements VerbraucherSchnittstell $this->initialisiereLokaleEinstellungen(); $this->aktualisiereVariablen(); + // Ein persistiertes Ladeende nach Neueinrichtung oder Modulupdate einmal neu pruefen. + $this->setzeZustand('FahrzeugGeladen', false); + $this->WriteAttributeInteger('LetzterGesetzterStrom', -1); + try { $this->pruefeKonfiguration(); } catch (Throwable $fehler) { diff --git a/tests/Symcon/modules/LadestationStandAlone.php b/tests/Symcon/modules/LadestationStandAlone.php index 9e84535..c5aed63 100644 --- a/tests/Symcon/modules/LadestationStandAlone.php +++ b/tests/Symcon/modules/LadestationStandAlone.php @@ -151,6 +151,29 @@ return static function (TestContext $test): void { ); }); + $test->runCase('ApplyChanges prueft ein gespeichertes Ladeende neu', static function ( + TestContext $test + ) use ($goEAntwort, $solarPause): void { + IPS_RequestAction($solarPause, 'Solarladen', false); + IPS_RequestAction($solarPause, 'Statuszyklus', false); + $test->assertSame( + true, + GetValue($test->objectByIdent('FahrzeugGeladen', $solarPause)) + ); + + IPS_SetProperty($solarPause, 'Testantwort', $goEAntwort(2, 2300)); + IPS_ApplyChanges($solarPause); + + $test->assertSame( + false, + GetValue($test->objectByIdent('FahrzeugGeladen', $solarPause)) + ); + $test->assertSame( + 3, + GetValue($test->objectByIdent('Fahrzeugstatus', $solarPause)) + ); + }); + $test->runCase('Solar- und Peakbetrieb liefern die Enelix-1-Angebote', static function ( TestContext $test ) use ($stationen, $anforderungSenden): void { From ccecc437e9b247081b9c9ef91b50d922e290c7b7 Mon Sep 17 00:00:00 2001 From: dh_Agent Date: Tue, 29 Sep 2026 09:34:37 +0000 Subject: [PATCH 4/6] fix(easee): wartende Ladevorgaenge starten --- CHANGELOG.md | 1 + EaseeGateway/module.php | 24 +++ LadestationGateway/module.php | 165 ++++++++++++++++++-- docs/module/Ladestation-Gateway/README.md | 16 +- libs/EaseeGatewayProtokoll.php | 3 +- libs/EaseeLadestatus.php | 20 +++ tests/EaseeGatewayProtokollTest.php | 18 +++ tests/EaseeLadestatusTest.php | 18 +++ tests/EaseeModuleStrukturTest.php | 6 + tests/Symcon/modules/EaseeGateway.php | 15 ++ tests/Symcon/modules/LadestationGateway.php | 18 +++ 11 files changed, 283 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 563cb15..38b7ecb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ Alle wesentlichen Aenderungen an Enelix EMS werden in dieser Datei dokumentiert. - Stufenlose Leistungsbereiche fuer Batteriespeicher. - Ladefreigabe neuer Ladestationsinstanzen standardmaessig aktiviert. - Easee-Ladestationen laden einen fehlenden Initialstatus ueber die Observations-API nach und starten bei noch unbekannter Ausgangsphase konservativ einphasig. +- Wartende Easee-Ladestationen erhalten bei positiver Stromvorgabe einmal pro Fahrzeugverbindung einen Startbefehl; Freigabe- und Blockiergruende werden diagnostiziert. - Fehlende oder veraltete Verbraucher blockieren die Verteilung an aktuelle Verbraucher nicht mehr. - Die Stoerungsueberwachung fordert einen fehlenden Geraetezugang innerhalb ihres Retry-Zyklus automatisch neu an. - Vollstaendige PHP-, Struktur- und Symcon-Modultests fuer alle Module. diff --git a/EaseeGateway/module.php b/EaseeGateway/module.php index d491571..8144b02 100644 --- a/EaseeGateway/module.php +++ b/EaseeGateway/module.php @@ -221,6 +221,12 @@ class EaseeGateway extends IPSModule $seriennummer, (float) ($anfrage['amps'] ?? -1) ), JSON_THROW_ON_ERROR); + + case 'StartCharging': + return json_encode( + $this->sendeLadestart($seriennummer), + JSON_THROW_ON_ERROR + ); } return json_encode([ @@ -670,6 +676,24 @@ class EaseeGateway extends IPSModule ); } + /** @return array */ + private function sendeLadestart(string $seriennummer): array + { + if ($seriennummer === '') { + return ['success' => false, 'error' => 'Seriennummer fehlt.']; + } + if ($this->ReadPropertyBoolean('Testmodus')) { + return ['success' => true, 'httpCode' => 200, 'body' => '{}']; + } + + return $this->autorisierteApiAnfrage( + 'POST', + '/api/chargers/' . rawurlencode($seriennummer) + . '/commands/start_charging', + '' + ); + } + /** @return array */ private function autorisierteApiAnfrage( string $methode, diff --git a/LadestationGateway/module.php b/LadestationGateway/module.php index 4a1b18e..383b3f3 100644 --- a/LadestationGateway/module.php +++ b/LadestationGateway/module.php @@ -41,7 +41,13 @@ class LadestationGateway extends IPSModule implements VerbraucherSchnittstelle 'Stoertext', 'GatewayVerbunden', 'ApiMaximalstrom', + 'EaseeAktiviert', + 'AutorisierungErforderlich', + 'RemoteStartErforderlich', + 'SmartCharging', + 'GrundKeinLadestrom', 'LetzterGeraetebefehl', + 'LetzterStartbefehl', 'LeistungsangebotDiagnose', ]; @@ -82,6 +88,11 @@ class LadestationGateway extends IPSModule implements VerbraucherSchnittstelle $this->RegisterAttributeString('EaseeBeobachtungen', '{}'); $this->RegisterAttributeInteger('LetzteBeobachtung', 0); $this->RegisterAttributeInteger('ApiMaximalstrom', 16); + $this->RegisterAttributeBoolean('ZustandEaseeAktiviert', true); + $this->RegisterAttributeBoolean('ZustandAutorisierungErforderlich', false); + $this->RegisterAttributeBoolean('ZustandRemoteStartErforderlich', false); + $this->RegisterAttributeBoolean('ZustandSmartCharging', false); + $this->RegisterAttributeInteger('ZustandGrundKeinLadestrom', 0); $this->RegisterAttributeBoolean('ZustandFahrzeugVerbunden', false); $this->RegisterAttributeBoolean('ZustandFahrzeugGeladen', false); $this->RegisterAttributeInteger('ZustandFahrzeugstatus', 0); @@ -99,7 +110,9 @@ class LadestationGateway extends IPSModule implements VerbraucherSchnittstelle $this->RegisterAttributeInteger('LetzteManagerID', 0); $this->RegisterAttributeInteger('LetzteVorgabeZeit', 0); $this->RegisterAttributeInteger('LetzterGesetzterStrom', -1); + $this->RegisterAttributeBoolean('LadestartFuerVerbindungGesendet', false); $this->RegisterAttributeString('LetzterGeraetebefehl', ''); + $this->RegisterAttributeString('LetzterStartbefehl', ''); $this->RegisterTimer( 'Meldezyklus', @@ -128,6 +141,7 @@ class LadestationGateway extends IPSModule implements VerbraucherSchnittstelle $this->aktualisiereVariablen(); $this->WriteAttributeBoolean('GatewayVerbunden', false); $this->WriteAttributeInteger('LetzterGesetzterStrom', -1); + $this->WriteAttributeBoolean('LadestartFuerVerbindungGesendet', false); try { $this->pruefeKonfiguration(); @@ -405,6 +419,11 @@ class LadestationGateway extends IPSModule implements VerbraucherSchnittstelle 'Phasenzahl', 'Istleistung_W', 'Ladestrom_A', + 'EaseeAktiviert', + 'AutorisierungErforderlich', + 'RemoteStartErforderlich', + 'SmartCharging', + 'GrundKeinLadestrom', ] as $feld) { $this->setzeZustand($feld, $status[$feld]); } @@ -416,6 +435,9 @@ class LadestationGateway extends IPSModule implements VerbraucherSchnittstelle 'ApiMaximalstrom', $status['ApiMaximalstrom_A'] ); + if (!$status['FahrzeugVerbunden']) { + $this->WriteAttributeBoolean('LadestartFuerVerbindungGesendet', false); + } $gatewayVerbunden = $this->ReadAttributeBoolean('GatewayVerbunden'); $phaseGueltig = in_array($status['Phasenzahl'], [1, 3], true); @@ -423,6 +445,7 @@ class LadestationGateway extends IPSModule implements VerbraucherSchnittstelle $phaseErforderlich = $status['Fahrzeugstatus'] === 3; $steuerbar = $gatewayVerbunden && $status['StatusGueltig'] + && $status['EaseeAktiviert'] && !$status['Stoerung'] && (!$phaseErforderlich || $phaseGueltig); @@ -473,7 +496,8 @@ class LadestationGateway extends IPSModule implements VerbraucherSchnittstelle if ($gatewayVerbunden && $status['StatusGueltig']) { $this->setzeLadestrom( $this->bestimmeWirksameSollleistung($angebot), - $regelPhasenzahl + $regelPhasenzahl, + $status['Fahrzeugstatus'] === 2 ); } else { $this->WriteAttributeInteger('LetzterGesetzterStrom', -1); @@ -512,34 +536,64 @@ class LadestationGateway extends IPSModule implements VerbraucherSchnittstelle return 0; } - private function setzeLadestrom(int $leistung, int $phasenzahl): void - { + private function setzeLadestrom( + int $leistung, + int $phasenzahl, + bool $wartetAufStart + ): void { $strom = $leistung === 0 ? 0 : LadestationRegler::stromFuerLeistung($leistung, $phasenzahl); - if ($strom === $this->ReadAttributeInteger('LetzterGesetzterStrom')) { - return; + if ($strom !== $this->ReadAttributeInteger('LetzterGesetzterStrom')) { + $antwort = $this->gatewayAnfrage([ + 'action' => 'SetDynamicChargerCurrent', + 'serialNumber' => $this->seriennummer(), + 'amps' => $strom, + ]); + if (!($antwort['success'] ?? false)) { + throw new RuntimeException( + (string) ($antwort['error'] ?? 'Easee-Stromvorgabe fehlgeschlagen.') + ); + } + + $befehl = json_encode([ + 'Aktion' => 'SetDynamicChargerCurrent', + 'Seriennummer' => $this->seriennummer(), + 'Ampere' => $strom, + ], JSON_THROW_ON_ERROR); + $this->WriteAttributeString('LetzterGeraetebefehl', $befehl); + $this->setzeSichtbareVariable('LetzterGeraetebefehl', $befehl); + $this->WriteAttributeInteger('LetzterGesetzterStrom', $strom); } + if ( + $strom > 0 + && $wartetAufStart + && !$this->ReadAttributeBoolean('LadestartFuerVerbindungGesendet') + ) { + $this->sendeLadestart(); + } + } + + private function sendeLadestart(): void + { $antwort = $this->gatewayAnfrage([ - 'action' => 'SetDynamicChargerCurrent', + 'action' => 'StartCharging', 'serialNumber' => $this->seriennummer(), - 'amps' => $strom, ]); if (!($antwort['success'] ?? false)) { throw new RuntimeException( - (string) ($antwort['error'] ?? 'Easee-Stromvorgabe fehlgeschlagen.') + (string) ($antwort['error'] ?? 'Easee-Ladestart fehlgeschlagen.') ); } $befehl = json_encode([ - 'Aktion' => 'SetDynamicChargerCurrent', + 'Aktion' => 'StartCharging', 'Seriennummer' => $this->seriennummer(), - 'Ampere' => $strom, ], JSON_THROW_ON_ERROR); - $this->WriteAttributeString('LetzterGeraetebefehl', $befehl); - $this->setzeSichtbareVariable('LetzterGeraetebefehl', $befehl); - $this->WriteAttributeInteger('LetzterGesetzterStrom', $strom); + $this->WriteAttributeBoolean('LadestartFuerVerbindungGesendet', true); + $this->WriteAttributeString('LetzterStartbefehl', $befehl); + $this->setzeSichtbareVariable('LetzterStartbefehl', $befehl); } private function seriennummer(): string @@ -650,6 +704,36 @@ class LadestationGateway extends IPSModule implements VerbraucherSchnittstelle 'Wert' => $this->ReadAttributeBoolean('ZustandSolarladen'), 'Einheit' => '', ], + [ + 'Kennung' => 'EaseeAktiviert', + 'Art' => 'Status', + 'Wert' => (bool) $this->leseZustand('EaseeAktiviert'), + 'Einheit' => '', + ], + [ + 'Kennung' => 'AutorisierungErforderlich', + 'Art' => 'Status', + 'Wert' => (bool) $this->leseZustand('AutorisierungErforderlich'), + 'Einheit' => '', + ], + [ + 'Kennung' => 'RemoteStartErforderlich', + 'Art' => 'Status', + 'Wert' => (bool) $this->leseZustand('RemoteStartErforderlich'), + 'Einheit' => '', + ], + [ + 'Kennung' => 'SmartCharging', + 'Art' => 'Status', + 'Wert' => (bool) $this->leseZustand('SmartCharging'), + 'Einheit' => '', + ], + [ + 'Kennung' => 'GrundKeinLadestrom', + 'Art' => 'Status', + 'Wert' => (int) $this->leseZustand('GrundKeinLadestrom'), + 'Einheit' => '', + ], [ 'Kennung' => 'Ladefehler', 'Art' => 'Stoerung', @@ -815,17 +899,53 @@ class LadestationGateway extends IPSModule implements VerbraucherSchnittstelle '', 210 ); + $this->RegisterVariableBoolean( + 'EaseeAktiviert', + 'Easee aktiviert', + '~Switch', + 220 + ); + $this->RegisterVariableBoolean( + 'AutorisierungErforderlich', + 'Autorisierung erforderlich', + '~Switch', + 230 + ); + $this->RegisterVariableBoolean( + 'RemoteStartErforderlich', + 'Remote-Start erforderlich', + '~Switch', + 240 + ); + $this->RegisterVariableBoolean( + 'SmartCharging', + 'Easee Smart Charging', + '~Switch', + 250 + ); + $this->RegisterVariableInteger( + 'GrundKeinLadestrom', + 'Grund kein Ladestrom', + '', + 260 + ); $this->RegisterVariableString( 'LetzterGeraetebefehl', 'Letzter Geraetebefehl', '', - 220 + 270 + ); + $this->RegisterVariableString( + 'LetzterStartbefehl', + 'Letzter Startbefehl', + '', + 280 ); $this->RegisterVariableString( 'LeistungsangebotDiagnose', 'Leistungsangebot', '', - 230 + 290 ); $this->SetValue( 'LeistungsangebotDiagnose', @@ -835,11 +955,16 @@ class LadestationGateway extends IPSModule implements VerbraucherSchnittstelle 'LetzterGeraetebefehl', $this->ReadAttributeString('LetzterGeraetebefehl') ); + $this->SetValue( + 'LetzterStartbefehl', + $this->ReadAttributeString('LetzterStartbefehl') + ); foreach (self::DIAGNOSE_VARIABLEN as $ident) { if (!in_array($ident, [ 'GatewayVerbunden', 'ApiMaximalstrom', 'LetzterGeraetebefehl', + 'LetzterStartbefehl', 'LeistungsangebotDiagnose', ], true)) { $this->setzeSichtbareVariable($ident, $this->leseZustand($ident)); @@ -874,6 +999,10 @@ class LadestationGateway extends IPSModule implements VerbraucherSchnittstelle switch ($ident) { case 'FahrzeugVerbunden': case 'FahrzeugGeladen': + case 'EaseeAktiviert': + case 'AutorisierungErforderlich': + case 'RemoteStartErforderlich': + case 'SmartCharging': case 'SollwertGueltig': case 'Verfuegbar': case 'AenderungMoeglich': @@ -882,6 +1011,7 @@ class LadestationGateway extends IPSModule implements VerbraucherSchnittstelle break; case 'Fahrzeugstatus': case 'Phasenzahl': + case 'GrundKeinLadestrom': case 'Sollleistung': $this->WriteAttributeInteger('Zustand' . $ident, (int) $wert); break; @@ -917,6 +1047,10 @@ class LadestationGateway extends IPSModule implements VerbraucherSchnittstelle if (in_array($ident, [ 'FahrzeugVerbunden', 'FahrzeugGeladen', + 'EaseeAktiviert', + 'AutorisierungErforderlich', + 'RemoteStartErforderlich', + 'SmartCharging', 'SollwertGueltig', 'Verfuegbar', 'AenderungMoeglich', @@ -927,6 +1061,7 @@ class LadestationGateway extends IPSModule implements VerbraucherSchnittstelle if (in_array($ident, [ 'Fahrzeugstatus', 'Phasenzahl', + 'GrundKeinLadestrom', 'Sollleistung', ], true)) { return $this->ReadAttributeInteger('Zustand' . $ident); diff --git a/docs/module/Ladestation-Gateway/README.md b/docs/module/Ladestation-Gateway/README.md index ff21307..cb29912 100644 --- a/docs/module/Ladestation-Gateway/README.md +++ b/docs/module/Ladestation-Gateway/README.md @@ -58,12 +58,15 @@ als ungueltig behandelt und ergeben bis zur gueltigen 1-/3-Phasenmeldung nur Immer sichtbar sind `Aktiv`, `FahrzeugVerbunden`, `FahrzeugGeladen`, `Fahrzeugstatus`, `Ladestrom` und `Phasenzahl`. Der normalisierte -Fahrzeugstatus ist `0` unbekannt, `1` getrennt, `2` bereit, `3` laedt, -`4` geladen oder `5` Fehler. +Fahrzeugstatus ist `0` unbekannt, `1` getrennt, `2` wartet auf Start, +`3` laedt, `4` geladen oder `5` Fehler. Optional sichtbar sind die gemeinsamen EMS-Diagnosewerte sowie -`GatewayVerbunden`, `ApiMaximalstrom`, `LetzterGeraetebefehl` und -`LeistungsangebotDiagnose`. +`GatewayVerbunden`, `ApiMaximalstrom`, `EaseeAktiviert`, +`AutorisierungErforderlich`, `RemoteStartErforderlich`, `SmartCharging`, +`GrundKeinLadestrom`, `LetzterGeraetebefehl`, `LetzterStartbefehl` und +`LeistungsangebotDiagnose`. `GrundKeinLadestrom=55` bedeutet bei Easee, dass +die Autorisierung fehlt. ## Regelung @@ -83,7 +86,10 @@ Angebot sofort neu und meldet die Aenderung kurz gebuendelt an den Manager. Bei einer Gateway-Unterbrechung werden Fahrzeug- und Phasenstatus sofort verworfen; erst ein neuer aktueller Gateway-Zustand gibt die Regelung wieder frei. Die eigentliche Vorgabe wird als dynamischer Ladestrom mit `minutes=0` -an Easee gesendet. +an Easee gesendet. Meldet Easee bei positiver Stromvorgabe weiterhin +`Awaiting Start`, sendet das Modul einmal pro Fahrzeugverbindung zusaetzlich +`start_charging`. Dieser Befehl autorisiert den Ladevorgang, uebersteuert aber +keinen vorhandenen Easee-Ladeplan. ## Managerkommunikation diff --git a/libs/EaseeGatewayProtokoll.php b/libs/EaseeGatewayProtokoll.php index 9af91bd..8d61e40 100644 --- a/libs/EaseeGatewayProtokoll.php +++ b/libs/EaseeGatewayProtokoll.php @@ -8,7 +8,8 @@ final class EaseeGatewayProtokoll { /** @var list */ public const BEOBACHTUNGEN = [ - 47, 48, 100, 104, 109, 110, 119, 120, 182, 183, 184, 185, 250, + 31, 41, 42, 43, 47, 48, 62, 96, 100, 102, 104, 109, 110, + 111, 112, 113, 114, 119, 120, 182, 183, 184, 185, 250, ]; public static function seriennummer(string $wert): string diff --git a/libs/EaseeLadestatus.php b/libs/EaseeLadestatus.php index bf0737d..200fab4 100644 --- a/libs/EaseeLadestatus.php +++ b/libs/EaseeLadestatus.php @@ -22,6 +22,11 @@ final class EaseeLadestatus * Istleistung_W: float, * Ladestrom_A: float, * ApiMaximalstrom_A: int, + * EaseeAktiviert: bool, + * AutorisierungErforderlich: bool, + * RemoteStartErforderlich: bool, + * SmartCharging: bool, + * GrundKeinLadestrom: int, * Stoerung: bool, * Stoertext: string * } @@ -65,6 +70,16 @@ final class EaseeLadestatus } } $apiMaximalstrom = max(6, min($grenzen)); + $easeeAktiviert = !array_key_exists('31', $beobachtungen) + || (bool) $beobachtungen['31']; + $autorisierungErforderlich = (bool) ( + $beobachtungen['42'] ?? $beobachtungen['41'] ?? false + ); + $remoteStartErforderlich = (bool) ($beobachtungen['43'] ?? false); + $smartCharging = (bool) ($beobachtungen['102'] ?? false); + $grundKeinLadestrom = is_numeric($beobachtungen['96'] ?? null) + ? (int) $beobachtungen['96'] + : 0; $fehlercode = (int) ($beobachtungen['119'] ?? 0); $stoerung = $betriebszustand === 5 || $fehlercode !== 0; @@ -85,6 +100,11 @@ final class EaseeLadestatus 'Istleistung_W' => $leistung, 'Ladestrom_A' => $ladestrom, 'ApiMaximalstrom_A' => $apiMaximalstrom, + 'EaseeAktiviert' => $easeeAktiviert, + 'AutorisierungErforderlich' => $autorisierungErforderlich, + 'RemoteStartErforderlich' => $remoteStartErforderlich, + 'SmartCharging' => $smartCharging, + 'GrundKeinLadestrom' => $grundKeinLadestrom, 'Stoerung' => $stoerung, 'Stoertext' => trim($stoertext), ]; diff --git a/tests/EaseeGatewayProtokollTest.php b/tests/EaseeGatewayProtokollTest.php index 9d96e8b..7dc2418 100644 --- a/tests/EaseeGatewayProtokollTest.php +++ b/tests/EaseeGatewayProtokollTest.php @@ -58,6 +58,24 @@ final class EaseeGatewayProtokollTest extends TestCase ], $beobachtungen); } + public function testFreigabediagnoseWirdAusBeobachtungenExtrahiert(): void + { + $beobachtungen = EaseeGatewayProtokoll::extrahiereBeobachtungen([ + 'serialNumber' => 'EH123456', + 'observations' => [ + ['id' => 42, 'value' => true], + ['id' => 43, 'value' => true], + ['id' => 96, 'value' => 55], + ], + ], ['EH123456']); + + self::assertSame([ + ['Seriennummer' => 'EH123456', 'ID' => 42, 'Wert' => true], + ['Seriennummer' => 'EH123456', 'ID' => 43, 'Wert' => true], + ['Seriennummer' => 'EH123456', 'ID' => 96, 'Wert' => 55], + ], $beobachtungen); + } + public function testSeriennummerWirdNormalisiert(): void { self::assertSame( diff --git a/tests/EaseeLadestatusTest.php b/tests/EaseeLadestatusTest.php index 2f3f100..cb42878 100644 --- a/tests/EaseeLadestatusTest.php +++ b/tests/EaseeLadestatusTest.php @@ -89,6 +89,24 @@ final class EaseeLadestatusTest extends TestCase self::assertSame(13, $status['ApiMaximalstrom_A']); } + public function testFreigabeUndGrundOhneLadestromKommenAusEaseeBeobachtungen(): void + { + $status = EaseeLadestatus::ausBeobachtungen([ + '31' => true, + '42' => true, + '43' => true, + '96' => 55, + '102' => true, + '109' => 2, + ], 16); + + self::assertTrue($status['EaseeAktiviert']); + self::assertTrue($status['AutorisierungErforderlich']); + self::assertTrue($status['RemoteStartErforderlich']); + self::assertTrue($status['SmartCharging']); + self::assertSame(55, $status['GrundKeinLadestrom']); + } + public function testNurSolarladenIstFestErzwungen(): void { self::assertFalse(EaseeLadestatus::solarladenErzwungen(0)); diff --git a/tests/EaseeModuleStrukturTest.php b/tests/EaseeModuleStrukturTest.php index a94f0ef..e16af2a 100644 --- a/tests/EaseeModuleStrukturTest.php +++ b/tests/EaseeModuleStrukturTest.php @@ -70,6 +70,12 @@ final class EaseeModuleStrukturTest extends TestCase self::assertStringContainsString("'Phasenzahl'", $modul); self::assertStringNotContainsString('7500', $modul); self::assertStringNotContainsString('Abfrageintervall', $modul); + self::assertStringContainsString("'action' => 'StartCharging'", $modul); + + $gateway = (string) file_get_contents( + __DIR__ . '/../EaseeGateway/module.php' + ); + self::assertStringContainsString('/commands/start_charging', $gateway); } /** @return array */ diff --git a/tests/Symcon/modules/EaseeGateway.php b/tests/Symcon/modules/EaseeGateway.php index 449e385..e825613 100644 --- a/tests/Symcon/modules/EaseeGateway.php +++ b/tests/Symcon/modules/EaseeGateway.php @@ -72,6 +72,21 @@ return static function (TestContext $test): void { $test->assertSame(200, $antwort['httpCode']); }); + $test->runCase('Ladestart wird ueber Gateway angenommen', static function ( + TestContext $test + ) use ($gateway): void { + $antwort = json_decode(ENELIXEASEE_ProcessStationRequest( + $gateway, + json_encode([ + 'action' => 'StartCharging', + 'serialNumber' => 'EH123456', + ], JSON_THROW_ON_ERROR) + ), true, 512, JSON_THROW_ON_ERROR); + + $test->assertSame(true, $antwort['success']); + $test->assertSame(200, $antwort['httpCode']); + }); + $test->runCase('Gebrochene Stromvorgabe wird abgelehnt', static function ( TestContext $test ) use ($gateway): void { diff --git a/tests/Symcon/modules/LadestationGateway.php b/tests/Symcon/modules/LadestationGateway.php index d59b5f0..9df9062 100644 --- a/tests/Symcon/modules/LadestationGateway.php +++ b/tests/Symcon/modules/LadestationGateway.php @@ -111,6 +111,10 @@ return static function (TestContext $test): void { $test->runCase('Wartezustand startet konservativ ohne aktive Phase', static function ( TestContext $test ) use ($sendeBeobachtung, $station): void { + $sendeBeobachtung('EH123456', 42, true); + $sendeBeobachtung('EH123456', 43, true); + $sendeBeobachtung('EH123456', 96, 55); + $sendeBeobachtung('EH123456', 102, true); $sendeBeobachtung('EH123456', 109, 2); $sendeBeobachtung('EH123456', 110, 0); IPS_RequestAction($station, 'Solarladen', false); @@ -132,6 +136,20 @@ return static function (TestContext $test): void { ), '"Ampere":16' )); + $test->assertTrue(str_contains( + (string) GetValue( + $test->objectByIdent('LetzterStartbefehl', $station) + ), + '"Aktion":"StartCharging"' + )); + $test->assertSame( + 55, + GetValue($test->objectByIdent('GrundKeinLadestrom', $station)) + ); + $test->assertSame( + true, + GetValue($test->objectByIdent('AutorisierungErforderlich', $station)) + ); IPS_RequestAction($station, 'Solarladen', true); $sendeBeobachtung('EH123456', 110, 30); From 5ff0d405d4b4ac579fca97296de1b980a440a383 Mon Sep 17 00:00:00 2001 From: dh_Agent Date: Tue, 29 Sep 2026 11:40:24 +0000 Subject: [PATCH 5/6] fix(manager): feste Leistungsangebote immer zuteilen --- docs/Schnittstelle.md | 6 ++++++ docs/module/Manager/README.md | 21 +++++++++++++-------- libs/ManagerRegler.php | 2 +- tests/ManagerReglerTest.php | 21 ++++++++++++++------- 4 files changed, 34 insertions(+), 16 deletions(-) diff --git a/docs/Schnittstelle.md b/docs/Schnittstelle.md index 4eec288..9fda990 100644 --- a/docs/Schnittstelle.md +++ b/docs/Schnittstelle.md @@ -90,6 +90,12 @@ melden, ohne dass der Manager seine interne Geraetelogik kennen muss. ## Feste Regeln - Prioritaeten beginnen bei 0; eine kleinere Zahl bedeutet hoehere Prioritaet. +- Jeder verfuegbare, synchronisierte Verbraucher mit einem nicht leeren + `Leistungswerte_W`-Angebot erhaelt einen Sollwert aus genau diesem Angebot. +- `AenderungMoeglich=false` kennzeichnet ein fixes Angebot und ist kein + Ausschlussgrund. Ein Angebot `[11000]` fuehrt deshalb zwingend zu `11000 W`. +- Prioritaeten verteilen nur die ueber den jeweiligen Mindestwert hinaus + verfuegbare Leistung; sie duerfen keinen angebotenen Mindestwert verdrängen. - `Leistungsquelle`: 0 nicht vorhanden, 1 berechnet, 2 gemessen. - Bei Leistungsquelle 0 ist `Istleistung_W` zwingend `null`. - Leistungsbereiche enthalten jeden ganzen Wattwert von `Von_W` bis `Bis_W`. diff --git a/docs/module/Manager/README.md b/docs/module/Manager/README.md index dbb8876..e61e765 100644 --- a/docs/module/Manager/README.md +++ b/docs/module/Manager/README.md @@ -302,14 +302,19 @@ der aktiven Betriebsart und der aktuellen Leistung aller frisch gemeldeten Verbraucher. Eine gültige Istleistung wird bevorzugt; fehlt sie, bleibt der im Zustand bestätigte Sollwert konservativ reserviert. -Nicht verfügbare, nicht änderbare oder aktuell angebotlose Verbraucher behalten -ihre Leistung und erhalten keine neue Vorgabe. Die übrigen Verbraucher werden -nach der gemeldeten PV- beziehungsweise Peak-Priorität und danach stabil nach -Instanz-ID sortiert. Von ihrem jeweils kleinsten erlaubten Leistungswert aus -wird das Budget in dieser Reihenfolge aufgefüllt. Einzelwerte und ganzzahlige -Bereiche werden direkt verarbeitet; Lücken werden nie durch unzulässige Werte -geschlossen. Eine verbleibende Abweichung wird im `Verbraucherstatus` -dokumentiert. +Nicht verfügbare oder aktuell angebotlose Verbraucher behalten ihre Leistung +und erhalten keine neue Vorgabe. Jeder verfügbare, synchronisierte Verbraucher +mit einem nicht leeren Angebot erhält dagegen einen Sollwert aus diesem Angebot. +Das gilt auch bei `AenderungMoeglich=false`: Ein fixes Angebot wie `[11000]` +wird mit genau `11000 W` zugeteilt. + +Die Verbraucher werden nach der gemeldeten PV- beziehungsweise Peak-Priorität +und danach stabil nach Instanz-ID sortiert. Zuerst reserviert der Manager für +jeden Verbraucher den kleinsten erlaubten Leistungswert. Nur das danach +verbleibende Budget wird in Prioritätsreihenfolge aufgefüllt. Einzelwerte und +ganzzahlige Bereiche werden direkt verarbeitet; Lücken werden nie durch +unzulässige Werte geschlossen. Eine verbleibende Abweichung wird im +`Verbraucherstatus` dokumentiert. Die Betriebsart wechselt unterhalb des Solar-Sollwerts zu `PV` und oberhalb der wirksamen Lastspitzengrenze zu `Peak`. Zwischen den beiden Zielwerten diff --git a/libs/ManagerRegler.php b/libs/ManagerRegler.php index bde24b4..7f0d10e 100644 --- a/libs/ManagerRegler.php +++ b/libs/ManagerRegler.php @@ -120,7 +120,7 @@ final class ManagerRegler $aktuelleLeistung += $leistung; $daten = $eintrag['Daten']; - if (!$daten['Verfuegbar'] || !$daten['AenderungMoeglich'] || $daten['Leistungswerte_W'] === []) { + if (!$daten['Verfuegbar'] || $daten['Leistungswerte_W'] === []) { $nichtSteuerbar += $leistung; continue; } diff --git a/tests/ManagerReglerTest.php b/tests/ManagerReglerTest.php index 31c840d..d9ba6b7 100644 --- a/tests/ManagerReglerTest.php +++ b/tests/ManagerReglerTest.php @@ -113,19 +113,19 @@ final class ManagerReglerTest extends TestCase self::assertSame(500, $ergebnis['Restabweichung_W']); } - public function testNichtAenderbarerVerbraucherReserviertSeineIstleistung(): void + public function testFestwertWirdUnabhaengigVonAenderungMoeglichZugewiesen(): void { - $fest = $this->verbraucher(10, 0, [0, 3000], 3000, false); + $fest = $this->verbraucher(10, 0, [3000], 3000, false); $steuerbar = $this->verbraucher(20, 1, [0, ['Von_W' => 1, 'Bis_W' => 4000]], 0); $ergebnis = ManagerRegler::verteile(3000.0, 5000.0, [$fest, $steuerbar], 'PrioritaetPV'); - self::assertSame(3000, $ergebnis['NichtSteuerbar_W']); - self::assertSame([20 => 2000], $ergebnis['Sollwerte']); + self::assertSame(0, $ergebnis['NichtSteuerbar_W']); + self::assertSame([10 => 3000, 20 => 2000], $ergebnis['Sollwerte']); self::assertSame(0, $ergebnis['Restabweichung_W']); } - public function testVeralteterVerbraucherBlockiertUeberschussverteilungNicht(): void + public function testFestwerteWerdenMitHoeherPriorisierterBatterieZugewiesen(): void { $ergebnis = ManagerRegler::verteile( -2411.0, @@ -147,7 +147,13 @@ final class ManagerReglerTest extends TestCase self::assertSame(12562, $ergebnis['Verteilbudget_W']); self::assertSame( - [44234 => 2411, 29743 => 0, 38676 => 0], + [ + 44234 => -12288, + 29743 => 0, + 38676 => 0, + 43878 => 13850, + 46052 => 11000, + ], $ergebnis['Sollwerte'] ); self::assertSame(0, $ergebnis['Restabweichung_W']); @@ -155,7 +161,7 @@ final class ManagerReglerTest extends TestCase public function testFehlendeIstleistungFaelltAufAngenommenenSollwertZurueck(): void { - $verbraucher = $this->verbraucher(10, 0, [0, 3000], null, false); + $verbraucher = $this->verbraucher(10, 0, [3000], null, false); $verbraucher['Daten']['Leistungsquelle'] = Nachrichtenvertrag::LEISTUNGSQUELLE_NICHT_VORHANDEN; $verbraucher['Daten']['Istleistung_W'] = null; $verbraucher['Daten']['Zustand'][0]['Wert'] = 3000; @@ -163,6 +169,7 @@ final class ManagerReglerTest extends TestCase $ergebnis = ManagerRegler::verteile(3000.0, 0.0, [$verbraucher], 'PrioritaetPV'); self::assertSame(3000, $ergebnis['AktuelleVerbraucherleistung_W']); + self::assertSame([10 => 3000], $ergebnis['Sollwerte']); self::assertSame(-3000, $ergebnis['Restabweichung_W']); } From 995b704e36405d462a76fa2c0679b33b64808ed4 Mon Sep 17 00:00:00 2001 From: dh_Agent Date: Tue, 29 Sep 2026 08:41:32 +0000 Subject: [PATCH 6/6] =?UTF-8?q?fix(manager):=20Prognoseberechtigungen=20ve?= =?UTF-8?q?rst=C3=A4ndlich=20diagnostizieren?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Manager/module.php | 12 +++++----- docs/module/Manager/README.md | 1 + libs/Lizenzpruefung.php | 13 +++++++++++ tests/LizenzpruefungTest.php | 41 +++++++++++++++++++++++++++++++++++ 4 files changed, 60 insertions(+), 7 deletions(-) diff --git a/Manager/module.php b/Manager/module.php index 7fa93bb..64d74d3 100644 --- a/Manager/module.php +++ b/Manager/module.php @@ -2263,12 +2263,7 @@ class Manager extends IPSModule implements ManagerSchnittstelle /** @param array $lease */ private function prognoseBerechtigungInLease(array $lease): bool { - return Lizenzpruefung::berechtigungsMenge($lease, Lizenzpruefung::NETZFAHRPLAN) > 0 - || (Lizenzpruefung::berechtigungsMenge($lease, Lizenzpruefung::PROGNOSE_PV) > 0 - && Lizenzpruefung::berechtigungsMenge( - $lease, - Lizenzpruefung::PROGNOSE_VERBRAUCH - ) > 0); + return Lizenzpruefung::hatPrognoseBerechtigung($lease); } private function berechtigungLizenziert(string $kennung, ?string $lizenzcode = null): bool @@ -2418,7 +2413,10 @@ class Manager extends IPSModule implements ManagerSchnittstelle if (!$lizenziert) { return 'Prognosen sind deaktiviert. Fuer die Manager-ID ' . $managerID - . ' fehlen forecast_pv plus forecast_load oder grid_schedule.'; + . ' ist keine Prognoseberechtigung vorhanden. Benoetigt wird entweder ' + . 'PV-Ertragsprognose zusammen mit Verbrauchsprognose oder der ' + . 'Intelligente Netzfahrplan. Nach der Erweiterung im Lizenzportal ' + . 'die Lizenz erneut pruefen und binden.'; } return ($aktiv ? 'Prognosen sind aktiviert. ' : 'Prognosen sind deaktiviert. ') diff --git a/docs/module/Manager/README.md b/docs/module/Manager/README.md index e61e765..7e4e611 100644 --- a/docs/module/Manager/README.md +++ b/docs/module/Manager/README.md @@ -279,6 +279,7 @@ stabile ID gebunden werden. | HTTP `429` | Zu viele Aktivierungsversuche; vor dem naechsten Versuch warten. | | `Lizenzserver nicht erreichbar` | DNS, Internetzugang, HTTPS und Systemzeit des Symcon-Systems pruefen. Eine bestehende Lease gilt nur bis `offlineUntil`. | | `Offline-Freigabe ist abgelaufen.` | Verbindung zum Lizenzserver wiederherstellen und Lizenz erneut pruefen. | +| `Keine Prognoseberechtigung vorhanden` | Im Lizenzportal entweder PV-Ertragsprognose zusammen mit Verbrauchsprognose oder den Intelligenten Netzfahrplan ergaenzen. Anschliessend im Manager `Lizenz pruefen und binden` ausloesen, die Prognose aktivieren und die Konfiguration speichern. | Fuer eine genauere Diagnose koennen die Diagnosevariablen eingeblendet werden. `Lizenzstatus` zeigt dann den aktuellen Zustand. Mit aktiviertem Debug-Logging diff --git a/libs/Lizenzpruefung.php b/libs/Lizenzpruefung.php index dcd267e..a6f80a5 100644 --- a/libs/Lizenzpruefung.php +++ b/libs/Lizenzpruefung.php @@ -117,6 +117,19 @@ final class Lizenzpruefung return $menge; } + /** + * Eine Prognose ist entweder durch den Netzfahrplan oder durch beide + * Einzelberechtigungen fuer PV und Verbrauch freigeschaltet. + * + * @param array $lease + */ + public static function hatPrognoseBerechtigung(array $lease): bool + { + return self::berechtigungsMenge($lease, self::NETZFAHRPLAN) > 0 + || (self::berechtigungsMenge($lease, self::PROGNOSE_PV) > 0 + && self::berechtigungsMenge($lease, self::PROGNOSE_VERBRAUCH) > 0); + } + /** * Rechnet freie, bestehende Einzellizenzen auf nicht separat lizenzierte Waermepumpen an. * diff --git a/tests/LizenzpruefungTest.php b/tests/LizenzpruefungTest.php index b9c736f..7225b17 100644 --- a/tests/LizenzpruefungTest.php +++ b/tests/LizenzpruefungTest.php @@ -228,6 +228,47 @@ final class LizenzpruefungTest extends TestCase Lizenzpruefung::pruefeVerbrauchermengen($lease, $pruefmengen); } + public function testPvUndVerbrauchGebenPrognoseFrei(): void + { + $lease = $this->lease('manager_standard'); + $lease['entitlements'][] = [ + 'catalog_key' => Lizenzpruefung::PROGNOSE_PV, + 'quantity' => 1, + 'status' => 'active', + ]; + $lease['entitlements'][] = [ + 'catalog_key' => Lizenzpruefung::PROGNOSE_VERBRAUCH, + 'quantity' => 1, + 'status' => 'active', + ]; + + self::assertTrue(Lizenzpruefung::hatPrognoseBerechtigung($lease)); + } + + public function testEinzelnePrognoseBerechtigungReichtNichtAus(): void + { + $lease = $this->lease('manager_standard'); + $lease['entitlements'][] = [ + 'catalog_key' => Lizenzpruefung::PROGNOSE_PV, + 'quantity' => 1, + 'status' => 'active', + ]; + + self::assertFalse(Lizenzpruefung::hatPrognoseBerechtigung($lease)); + } + + public function testNetzfahrplanGibtPrognoseAlleineFrei(): void + { + $lease = $this->lease('manager_standard'); + $lease['entitlements'][] = [ + 'catalog_key' => Lizenzpruefung::NETZFAHRPLAN, + 'quantity' => 1, + 'status' => 'active', + ]; + + self::assertTrue(Lizenzpruefung::hatPrognoseBerechtigung($lease)); + } + public function testLizenzcodeWirdWieAufDemServerNormalisiert(): void { self::assertSame(