diff --git a/LadestationStandAlone/form.json b/LadestationStandAlone/form.json new file mode 100644 index 0000000..c2a6a05 --- /dev/null +++ b/LadestationStandAlone/form.json @@ -0,0 +1,155 @@ +{ + "elements": [ + { + "type": "ExpansionPanel", + "caption": "Manager und Zeitverhalten", + "expanded": true, + "items": [ + { + "type": "NumberSpinner", + "name": "PrioritaetPV", + "caption": "Prioritaet PV", + "minimum": 0 + }, + { + "type": "NumberSpinner", + "name": "PrioritaetPeak", + "caption": "Prioritaet Peak", + "minimum": 0 + }, + { + "type": "NumberSpinner", + "name": "Meldeintervall", + "caption": "Meldeintervall", + "suffix": " s", + "minimum": 1 + }, + { + "type": "NumberSpinner", + "name": "VorgabeTimeout", + "caption": "Vorgabe-Timeout", + "suffix": " s", + "minimum": 1 + }, + { + "type": "NumberSpinner", + "name": "Abfrageintervall", + "caption": "Geraetestatus abfragen", + "suffix": " s", + "minimum": 1 + } + ] + }, + { + "type": "ExpansionPanel", + "caption": "Ladestation", + "expanded": true, + "items": [ + { + "type": "Select", + "name": "Geraetetyp", + "caption": "Geraetetyp", + "options": [ + { + "caption": "Nicht konfiguriert", + "value": 0 + }, + { + "caption": "go-e Charger (alte API)", + "value": 1 + }, + { + "caption": "go-e Charger Gemini / Gemini flex", + "value": 2 + }, + { + "caption": "smart-me Pico", + "value": 3 + } + ] + }, + { + "type": "ValidationTextBox", + "name": "Geraeteadresse", + "caption": "IP-Adresse oder Hostname (go-e)" + }, + { + "type": "ValidationTextBox", + "name": "GeraeteID", + "caption": "Geraete-ID (Pico)" + }, + { + "type": "ValidationTextBox", + "name": "Seriennummer", + "caption": "Seriennummer (Pico)" + }, + { + "type": "ValidationTextBox", + "name": "Benutzername", + "caption": "Benutzername (Pico)" + }, + { + "type": "PasswordTextBox", + "name": "Passwort", + "caption": "Passwort (Pico)" + }, + { + "type": "NumberSpinner", + "name": "MaximalerLadestrom", + "caption": "Maximaler Ladestrom", + "suffix": " A", + "minimum": 6, + "maximum": 32 + }, + { + "type": "CheckBox", + "name": "Ladefreigabe", + "caption": "Ladefreigabe beim Start" + }, + { + "type": "CheckBox", + "name": "Solarladen", + "caption": "Solarladen beim Start" + } + ] + }, + { + "type": "ExpansionPanel", + "caption": "Darstellung und Diagnose", + "items": [ + { + "type": "CheckBox", + "name": "EinstellungenInVisu", + "caption": "Ladefreigabe und Solarladen in der Visualisierung anzeigen" + }, + { + "type": "CheckBox", + "name": "DiagnosevariablenAnzeigen", + "caption": "Diagnosevariablen anzeigen" + }, + { + "type": "CheckBox", + "name": "LoggingEin", + "caption": "Diagnoseprotokoll aktivieren" + } + ] + } + ], + "status": [ + { + "code": 102, + "icon": "active", + "caption": "Aktiv" + }, + { + "code": 201, + "icon": "error", + "caption": "Konfiguration ungueltig" + }, + { + "code": 202, + "icon": "error", + "caption": "Geraetekommunikation fehlgeschlagen" + } + ] +} diff --git a/LadestationStandAlone/module.json b/LadestationStandAlone/module.json new file mode 100644 index 0000000..c540995 --- /dev/null +++ b/LadestationStandAlone/module.json @@ -0,0 +1,14 @@ +{ + "id": "{0D94913C-0F31-4C29-A685-6EB4AE58E55D}", + "name": "LadestationStandAlone", + "type": 3, + "vendor": "Enelix", + "aliases": [ + "Ladestation Stand-Alone" + ], + "parentRequirements": [], + "childRequirements": [], + "implemented": [], + "prefix": "ENELIX", + "url": "https://git.belevo.ch/ENELIX/Enelix-EMS/src/branch/develop/LadestationStandAlone" +} diff --git a/LadestationStandAlone/module.php b/LadestationStandAlone/module.php new file mode 100644 index 0000000..f89e880 --- /dev/null +++ b/LadestationStandAlone/module.php @@ -0,0 +1,763 @@ + */ + private const DIAGNOSE_VARIABLEN = [ + 'Istleistung', + 'Leistungsquelle', + 'Sollleistung', + 'SollwertGueltig', + 'Verfuegbar', + 'AenderungMoeglich', + 'Stoerung', + 'Stoertext', + 'LetzterGeraetebefehl', + 'LeistungsangebotDiagnose', + ]; + + public function Create(): void + { + parent::Create(); + + $this->registriereVerbraucherBasis(); + $this->RegisterPropertyInteger('Geraetetyp', 0); + $this->RegisterPropertyString('Geraeteadresse', ''); + $this->RegisterPropertyString('GeraeteID', ''); + $this->RegisterPropertyString('Seriennummer', ''); + $this->RegisterPropertyString('Benutzername', ''); + $this->RegisterPropertyString('Passwort', ''); + $this->RegisterPropertyInteger('MaximalerLadestrom', 16); + $this->RegisterPropertyInteger('Abfrageintervall', 5); + $this->RegisterPropertyBoolean('Ladefreigabe', false); + $this->RegisterPropertyBoolean('Solarladen', true); + $this->RegisterPropertyBoolean('DiagnosevariablenAnzeigen', false); + + // Ausschliesslich fuer den automatisierten Symcon-Funktionstest. + $this->RegisterPropertyBoolean('Testmodus', false); + $this->RegisterPropertyString('Testantwort', ''); + + $this->RegisterVariableBoolean('FahrzeugVerbunden', 'Fahrzeug verbunden', '~Switch', 100); + $this->RegisterVariableBoolean('FahrzeugGeladen', 'Fahrzeug geladen', '~Switch', 110); + $this->RegisterVariableInteger('Fahrzeugstatus', 'Fahrzeugstatus', '', 120); + $this->RegisterVariableFloat('Ladestrom', 'Ladestrom', '', 130); + $this->RegisterVariableInteger('Phasenzahl', 'Phasenzahl', '', 140); + + $this->RegisterAttributeBoolean('InitialwerteGesetzt', false); + $this->RegisterAttributeBoolean('KonfigurationLadefreigabe', false); + $this->RegisterAttributeBoolean('KonfigurationSolarladen', true); + $this->RegisterAttributeBoolean('ZustandLadefreigabe', false); + $this->RegisterAttributeBoolean('ZustandSolarladen', true); + $this->RegisterAttributeBoolean('ZustandFahrzeugVerbunden', false); + $this->RegisterAttributeBoolean('ZustandFahrzeugGeladen', false); + $this->RegisterAttributeInteger('ZustandFahrzeugstatus', 0); + $this->RegisterAttributeFloat('ZustandLadestrom', 0.0); + $this->RegisterAttributeInteger('ZustandPhasenzahl', 0); + $this->RegisterAttributeFloat('ZustandIstleistung', 0.0); + $this->RegisterAttributeInteger('ZustandSollleistung', 0); + $this->RegisterAttributeBoolean('ZustandSollwertGueltig', false); + $this->RegisterAttributeBoolean('ZustandVerfuegbar', false); + $this->RegisterAttributeBoolean('ZustandAenderungMoeglich', false); + $this->RegisterAttributeBoolean('ZustandStoerung', false); + $this->RegisterAttributeString('ZustandStoertext', ''); + $this->RegisterAttributeString('Leistungsangebot', '[0]'); + $this->RegisterAttributeString('Betriebsart', Nachrichtenvertrag::BETRIEBSART_PV); + $this->RegisterAttributeInteger('LetzteManagerID', 0); + $this->RegisterAttributeInteger('LetzteVorgabeZeit', 0); + $this->RegisterAttributeInteger('LetzterGesetzterStrom', -1); + + $this->RegisterTimer( + 'Statuszyklus', + 0, + "IPS_RequestAction(\$_IPS['TARGET'], 'Statuszyklus', false);" + ); + $this->RegisterTimer( + 'Meldezyklus', + 0, + "IPS_RequestAction(\$_IPS['TARGET'], 'Melden', false);" + ); + $this->RegisterTimer( + 'RueckmeldungVerzoegert', + 0, + "IPS_RequestAction(\$_IPS['TARGET'], 'Melden', true);" + ); + $this->RegisterTimer( + 'VorgabeTimeout', + 0, + "IPS_RequestAction(\$_IPS['TARGET'], 'VorgabeTimeout', false);" + ); + } + + public function ApplyChanges(): void + { + parent::ApplyChanges(); + + $this->initialisiereLokaleEinstellungen(); + $this->aktualisiereVariablen(); + + try { + $this->pruefeKonfiguration(); + } catch (Throwable $fehler) { + $this->deaktiviereTimer(); + $this->setzeZustand('Verfuegbar', false); + $this->setzeZustand('AenderungMoeglich', false); + $this->setzeStoerung($fehler->getMessage()); + $this->SetStatus(self::STATUS_KONFIGURATION_UNGUELTIG); + $this->protokolliere('Konfiguration', $fehler->getMessage()); + return; + } + + $this->SetTimerInterval('Statuszyklus', $this->ReadPropertyInteger('Abfrageintervall') * 1000); + $this->SetTimerInterval('Meldezyklus', $this->ReadPropertyInteger('Meldeintervall') * 1000); + $this->aktualisiere(true); + } + + public function RequestAction($ident, $wert): void + { + switch ($ident) { + case 'Aktiv': + $this->SetValue('Aktiv', (bool) $wert); + if (!(bool) $wert) { + $this->setzeZustand('SollwertGueltig', false); + } + $this->aktualisiere(true); + return; + + case 'Ladefreigabe': + case 'Solarladen': + $this->WriteAttributeBoolean('Zustand' . $ident, (bool) $wert); + $this->setzeSichtbareVariable($ident, (bool) $wert); + $this->setzeZustand('SollwertGueltig', false); + $this->aktualisiere(true); + return; + + case 'Statuszyklus': + $this->aktualisiere(true); + return; + + case 'VorgabeTimeout': + $this->SetTimerInterval('VorgabeTimeout', 0); + $this->setzeZustand('SollwertGueltig', false); + $this->aktualisiere(true); + return; + + case 'Melden': + if ((bool) $wert) { + $this->SetTimerInterval('RueckmeldungVerzoegert', 0); + } + $this->aktualisiere(false); + $this->sendeVerbraucherdaten(); + return; + + case 'ManagerdatenEmpfangen': + if (!is_string($wert)) { + throw new InvalidArgumentException('Managerdaten muessen als JSON uebergeben werden.'); + } + $daten = json_decode($wert, true, 512, JSON_THROW_ON_ERROR); + if (!is_array($daten)) { + throw new InvalidArgumentException('Managerdaten muessen ein JSON-Objekt sein.'); + } + $this->ManagerdatenEmpfangen($daten); + return; + } + + throw new InvalidArgumentException('Unbekannte Aktion: ' . $ident); + } + + /** @param array $daten */ + public function ManagerdatenEmpfangen(array $daten): void + { + Nachrichtenvertrag::pruefeManagerdaten($daten); + if ($daten['Kopf']['EmpfaengerID'] !== $this->InstanceID) { + throw new InvalidArgumentException('Managerdaten sind an eine andere Instanz adressiert.'); + } + + $managerID = $daten['Kopf']['AbsenderID']; + if (!in_array($managerID, $this->zugeordneteManagerIDs(), true)) { + throw new InvalidArgumentException('Der Manager hat diese Ladestation nicht aktiv zugeordnet.'); + } + + $this->WriteAttributeString('Betriebsart', $daten['Betriebsart']); + $this->aktualisiere(false, false); + $sollleistung = $daten['Sollleistung_W']; + if ($sollleistung === null) { + if ((bool) $this->leseZustand('SollwertGueltig') + && !in_array((int) $this->leseZustand('Sollleistung'), $this->leseLeistungsangebot(), true) + ) { + $this->setzeZustand('SollwertGueltig', false); + $this->aktualisiere(false, false); + } + $this->SetTimerInterval('RueckmeldungVerzoegert', 100); + return; + } + if (!in_array($sollleistung, $this->leseLeistungsangebot(), true)) { + throw new InvalidArgumentException( + 'Sollleistung_W liegt nicht im aktuell gemeldeten Leistungsangebot.' + ); + } + + $this->WriteAttributeInteger('LetzteManagerID', $managerID); + $this->WriteAttributeInteger('LetzteVorgabeZeit', time()); + $this->setzeZustand('Sollleistung', $sollleistung); + $this->setzeZustand('SollwertGueltig', true); + $this->SetTimerInterval('VorgabeTimeout', $this->ReadPropertyInteger('VorgabeTimeout') * 1000); + $this->aktualisiere(false, false); + $this->SetTimerInterval('RueckmeldungVerzoegert', 100); + } + + private function aktualisiere(bool $meldungPlanen, bool $statusLesen = true): void + { + try { + if ($statusLesen) { + $this->leseGeraetestatus(); + } + + $aktiv = (bool) $this->GetValue('Aktiv'); + $ladefreigabe = $this->ReadAttributeBoolean('ZustandLadefreigabe'); + $verbunden = $this->ReadAttributeBoolean('ZustandFahrzeugVerbunden'); + $geladen = $this->ReadAttributeBoolean('ZustandFahrzeugGeladen'); + $phasenzahl = $this->ReadAttributeInteger('ZustandPhasenzahl'); + $maximalstrom = $this->ReadPropertyInteger('MaximalerLadestrom'); + $solarladen = $this->ReadAttributeBoolean('ZustandSolarladen'); + $peakbetrieb = $this->ReadAttributeString('Betriebsart') + === Nachrichtenvertrag::BETRIEBSART_PEAK; + $verfuegbar = LadestationRegler::leistungsangebot( + $aktiv, + $ladefreigabe, + $verbunden, + $geladen, + $phasenzahl, + $maximalstrom, + $solarladen, + false + ) !== [0]; + $angebot = LadestationRegler::leistungsangebot( + $aktiv, + $ladefreigabe, + $verbunden, + $geladen, + $phasenzahl, + $maximalstrom, + $solarladen, + $peakbetrieb + ); + $angebotJson = json_encode($angebot, JSON_THROW_ON_ERROR); + $this->WriteAttributeString('Leistungsangebot', $angebotJson); + $this->setzeSichtbareVariable('LeistungsangebotDiagnose', $angebotJson); + $this->setzeZustand('Verfuegbar', $verfuegbar); + $this->setzeZustand('AenderungMoeglich', $verfuegbar && count($angebot) > 1); + + $sollleistung = $this->bestimmeWirksameSollleistung($angebot); + $this->setzeLadestrom($sollleistung); + $this->setzeStoerung(''); + $this->SetStatus(self::STATUS_AKTIV); + } catch (Throwable $fehler) { + $this->setzeZustand('Verfuegbar', false); + $this->setzeZustand('AenderungMoeglich', false); + $this->setzeStoerung($fehler->getMessage()); + $this->SetStatus(self::STATUS_KOMMUNIKATIONSFEHLER); + $this->protokolliere('Geraetekommunikation', $fehler->getMessage()); + } + + if ($meldungPlanen) { + $this->SetTimerInterval('RueckmeldungVerzoegert', 100); + } + } + + private function leseGeraetestatus(): void + { + $transport = new LadestationTransport( + fn (array $anfrage): string => $this->fuehreHttpAnfrageAus($anfrage) + ); + $antwort = $transport->statusLesen( + $this->ReadPropertyInteger('Geraetetyp'), + $this->geraetekonfiguration() + ); + $rohstatus = LadestationAdapter::dekodiereStatus( + $this->ReadPropertyInteger('Geraetetyp'), + $antwort + ); + $status = LadestationRegler::normalisiereStatus( + $rohstatus, + $this->ReadAttributeBoolean('ZustandFahrzeugVerbunden') + ); + + foreach ($status as $ident => $wert) { + $this->setzeZustand($ident, $wert); + } + } + + /** @param list $angebot */ + private function bestimmeWirksameSollleistung(array $angebot): int + { + if ((bool) $this->leseZustand('SollwertGueltig')) { + $sollleistung = (int) $this->leseZustand('Sollleistung'); + if (in_array($sollleistung, $angebot, true)) { + return $sollleistung; + } + $this->setzeZustand('SollwertGueltig', false); + } + + if (!$this->ReadAttributeBoolean('ZustandSolarladen') && $angebot !== [0]) { + return $angebot[count($angebot) - 1]; + } + + return 0; + } + + private function setzeLadestrom(int $leistung): void + { + $phasenzahl = $this->ReadAttributeInteger('ZustandPhasenzahl'); + $strom = $leistung === 0 + ? 0 + : LadestationRegler::stromFuerLeistung($leistung, $phasenzahl); + if ($strom === $this->ReadAttributeInteger('LetzterGesetzterStrom')) { + return; + } + + $transport = new LadestationTransport( + fn (array $anfrage): string => $this->fuehreHttpAnfrageAus($anfrage) + ); + $transport->ladestromSetzen( + $this->ReadPropertyInteger('Geraetetyp'), + $this->geraetekonfiguration(), + $strom + ); + $this->WriteAttributeInteger('LetzterGesetzterStrom', $strom); + } + + /** + * @param array $anfrage + */ + private function fuehreHttpAnfrageAus(array $anfrage): string + { + $protokoll = [ + 'Methode' => $anfrage['Methode'], + 'URL' => $anfrage['URL'], + ]; + $this->setzeSichtbareVariable( + 'LetzterGeraetebefehl', + json_encode($protokoll, JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR) + ); + + if ($this->ReadPropertyBoolean('Testmodus')) { + $url = (string) $anfrage['URL']; + if (str_ends_with($url, '/api/status') + || str_ends_with($url, '/mqtt?payload=') + || str_contains($url, '/pico/charging/') + ) { + return $this->ReadPropertyString('Testantwort'); + } + return '{}'; + } + + $curl = curl_init(); + if ($curl === false) { + throw new RuntimeException('HTTP-Anfrage konnte nicht initialisiert werden.'); + } + + curl_setopt($curl, CURLOPT_URL, (string) $anfrage['URL']); + curl_setopt($curl, CURLOPT_RETURNTRANSFER, true); + curl_setopt($curl, CURLOPT_CONNECTTIMEOUT, 5); + curl_setopt($curl, CURLOPT_TIMEOUT, 10); + if ($anfrage['Methode'] === 'POST') { + curl_setopt($curl, CURLOPT_POST, true); + } + if (isset($anfrage['Benutzername'], $anfrage['Passwort'])) { + curl_setopt($curl, CURLOPT_HTTPAUTH, CURLAUTH_BASIC); + curl_setopt( + $curl, + CURLOPT_USERPWD, + (string) $anfrage['Benutzername'] . ':' . (string) $anfrage['Passwort'] + ); + } + + $antwort = curl_exec($curl); + if ($antwort === false) { + $fehler = curl_error($curl); + curl_close($curl); + throw new RuntimeException('HTTP-Anfrage fehlgeschlagen: ' . $fehler); + } + $status = (int) curl_getinfo($curl, CURLINFO_HTTP_CODE); + curl_close($curl); + if ($status >= 400) { + throw new RuntimeException('Geraet antwortet mit HTTP-Status ' . $status . '.'); + } + + return (string) $antwort; + } + + /** @return array */ + private function geraetekonfiguration(): array + { + return [ + 'Geraeteadresse' => $this->ReadPropertyString('Geraeteadresse'), + 'GeraeteID' => $this->ReadPropertyString('GeraeteID'), + 'Seriennummer' => $this->ReadPropertyString('Seriennummer'), + 'Benutzername' => $this->ReadPropertyString('Benutzername'), + 'Passwort' => $this->ReadPropertyString('Passwort'), + ]; + } + + private function sendeVerbraucherdaten(): void + { + foreach ($this->zugeordneteManagerIDs() as $managerID) { + $daten = $this->baueVerbraucherdaten($managerID); + Nachrichtenvertrag::pruefeVerbraucherdaten($daten); + try { + IPS_RequestAction( + $managerID, + 'VerbraucherdatenEmpfangen', + json_encode($daten, JSON_THROW_ON_ERROR) + ); + } catch (Throwable $fehler) { + $this->protokolliere('Managerkommunikation', $fehler->getMessage()); + } + } + } + + /** @return array */ + private function baueVerbraucherdaten(int $managerID): array + { + $stoerung = (bool) $this->leseZustand('Stoerung'); + + return [ + 'Kopf' => [ + 'Version' => Nachrichtenvertrag::VERSION, + 'AbsenderID' => $this->InstanceID, + 'EmpfaengerID' => $managerID, + 'Zeitpunkt' => time(), + ], + 'Betriebsart' => $this->ReadAttributeString('Betriebsart'), + 'PrioritaetPV' => $this->ReadPropertyInteger('PrioritaetPV'), + 'PrioritaetPeak' => $this->ReadPropertyInteger('PrioritaetPeak'), + 'Leistungswerte_W' => $this->leseLeistungsangebot(), + 'AenderungMoeglich' => (bool) $this->leseZustand('AenderungMoeglich'), + 'Verfuegbar' => (bool) $this->leseZustand('Verfuegbar'), + 'Istleistung_W' => (float) $this->leseZustand('Istleistung'), + 'Leistungsquelle' => Nachrichtenvertrag::LEISTUNGSQUELLE_GEMESSEN, + 'Zustand' => [ + [ + 'Kennung' => 'Sollleistung_W', + 'Art' => 'Sollwert', + 'Wert' => (bool) $this->leseZustand('SollwertGueltig') + ? (int) $this->leseZustand('Sollleistung') + : null, + 'Einheit' => 'W', + ], + [ + 'Kennung' => 'FahrzeugVerbunden', + 'Art' => 'Status', + 'Wert' => (bool) $this->leseZustand('FahrzeugVerbunden'), + 'Einheit' => '', + ], + [ + 'Kennung' => 'FahrzeugGeladen', + 'Art' => 'Status', + 'Wert' => (bool) $this->leseZustand('FahrzeugGeladen'), + 'Einheit' => '', + ], + [ + 'Kennung' => 'Fahrzeugstatus', + 'Art' => 'Status', + 'Wert' => (int) $this->leseZustand('Fahrzeugstatus'), + 'Einheit' => '', + ], + [ + 'Kennung' => 'Phasenzahl', + 'Art' => 'Status', + 'Wert' => (int) $this->leseZustand('Phasenzahl'), + 'Einheit' => '', + ], + [ + 'Kennung' => 'Ladestrom_A', + 'Art' => 'Istwert', + 'Wert' => (float) $this->leseZustand('Ladestrom'), + 'Einheit' => 'A', + ], + [ + 'Kennung' => 'Ladefreigabe', + 'Art' => 'Status', + 'Wert' => $this->ReadAttributeBoolean('ZustandLadefreigabe'), + 'Einheit' => '', + ], + [ + 'Kennung' => 'Solarladen', + 'Art' => 'Status', + 'Wert' => $this->ReadAttributeBoolean('ZustandSolarladen'), + 'Einheit' => '', + ], + [ + 'Kennung' => 'Ladefehler', + 'Art' => 'Stoerung', + 'Wert' => $stoerung, + 'Einheit' => '', + 'Text' => (string) $this->leseZustand('Stoertext'), + ], + ], + ]; + } + + /** @return list */ + private function zugeordneteManagerIDs(): array + { + $ergebnis = []; + foreach (IPS_GetInstanceListByModuleID(self::MANAGER_MODULE_ID) as $managerID) { + try { + $automatisch = (bool) IPS_GetProperty($managerID, 'AutomatischeSuche'); + $property = $automatisch ? 'AutomatischeVerbraucherZuordnung' : 'VerbraucherZuordnung'; + $zuordnung = $this->leseManagerZuordnung($managerID, $property); + if ($automatisch && $zuordnung === []) { + $zuordnung = $this->leseManagerZuordnung($managerID, 'VerbraucherZuordnung'); + } + } catch (Throwable $fehler) { + continue; + } + + foreach ($zuordnung as $eintrag) { + if (!is_array($eintrag) || ($eintrag['Aktiv'] ?? false) !== true) { + continue; + } + $instanzID = $eintrag['InstanzID'] ?? $eintrag['Verbraucher'] ?? null; + if ($instanzID === $this->InstanceID) { + $ergebnis[] = (int) $managerID; + break; + } + } + } + + return array_values(array_unique($ergebnis)); + } + + /** @return array */ + private function leseManagerZuordnung(int $managerID, string $property): array + { + $zuordnung = json_decode( + (string) IPS_GetProperty($managerID, $property), + true, + 512, + JSON_THROW_ON_ERROR + ); + + return is_array($zuordnung) ? $zuordnung : []; + } + + private function pruefeKonfiguration(): void + { + foreach (['PrioritaetPV', 'PrioritaetPeak'] as $property) { + if ($this->ReadPropertyInteger($property) < 0) { + throw new InvalidArgumentException($property . ' muss mindestens 0 sein.'); + } + } + foreach (['Meldeintervall', 'VorgabeTimeout', 'Abfrageintervall'] as $property) { + if ($this->ReadPropertyInteger($property) <= 0) { + throw new InvalidArgumentException($property . ' muss groesser als 0 sein.'); + } + } + $maximalstrom = $this->ReadPropertyInteger('MaximalerLadestrom'); + if ($maximalstrom < 6 || $maximalstrom > 32) { + throw new InvalidArgumentException('MaximalerLadestrom muss zwischen 6 und 32 A liegen.'); + } + + $geraetetyp = $this->ReadPropertyInteger('Geraetetyp'); + $konfiguration = $this->geraetekonfiguration(); + LadestationAdapter::statusAnfrage($geraetetyp, $konfiguration); + LadestationAdapter::steuerAnfragen($geraetetyp, $konfiguration, 0); + } + + private function initialisiereLokaleEinstellungen(): void + { + $initialisiert = $this->ReadAttributeBoolean('InitialwerteGesetzt'); + $ladefreigabe = $this->ReadPropertyBoolean('Ladefreigabe'); + $solarladen = $this->ReadPropertyBoolean('Solarladen'); + + if (!$initialisiert + || $ladefreigabe !== $this->ReadAttributeBoolean('KonfigurationLadefreigabe') + ) { + $this->WriteAttributeBoolean('ZustandLadefreigabe', $ladefreigabe); + } + if (!$initialisiert + || $solarladen !== $this->ReadAttributeBoolean('KonfigurationSolarladen') + ) { + $this->WriteAttributeBoolean('ZustandSolarladen', $solarladen); + } + + $this->WriteAttributeBoolean('KonfigurationLadefreigabe', $ladefreigabe); + $this->WriteAttributeBoolean('KonfigurationSolarladen', $solarladen); + $this->WriteAttributeBoolean('InitialwerteGesetzt', true); + } + + private function aktualisiereVariablen(): void + { + if ($this->ReadPropertyBoolean('EinstellungenInVisu')) { + $this->RegisterVariableBoolean('Ladefreigabe', 'Ladefreigabe', '~Switch', 150); + $this->RegisterVariableBoolean('Solarladen', 'Solarladen', '~Switch', 160); + $this->EnableAction('Ladefreigabe'); + $this->EnableAction('Solarladen'); + $this->SetValue('Ladefreigabe', $this->ReadAttributeBoolean('ZustandLadefreigabe')); + $this->SetValue('Solarladen', $this->ReadAttributeBoolean('ZustandSolarladen')); + } else { + $this->entferneVariable('Ladefreigabe'); + $this->entferneVariable('Solarladen'); + } + + if ($this->ReadPropertyBoolean('DiagnosevariablenAnzeigen')) { + $this->registriereVerbraucherDiagnose(); + $this->RegisterVariableString('LetzterGeraetebefehl', 'Letzter Geraetebefehl', '', 200); + $this->RegisterVariableString('LeistungsangebotDiagnose', 'Leistungsangebot', '', 210); + $this->SetValue( + 'LeistungsangebotDiagnose', + $this->ReadAttributeString('Leistungsangebot') + ); + foreach (self::DIAGNOSE_VARIABLEN as $ident) { + if (!in_array($ident, ['LetzterGeraetebefehl', 'LeistungsangebotDiagnose'], true)) { + $this->setzeSichtbareVariable($ident, $this->leseZustand($ident)); + } + } + } else { + foreach (self::DIAGNOSE_VARIABLEN as $ident) { + $this->entferneVariable($ident); + } + } + } + + private function entferneVariable(string $ident): void + { + $id = @$this->GetIDForIdent($ident); + if (is_int($id) && $id > 0 && IPS_VariableExists($id)) { + $this->UnregisterVariable($ident); + } + } + + /** @param mixed $wert */ + private function setzeZustand(string $ident, $wert): void + { + switch ($ident) { + case 'FahrzeugVerbunden': + case 'FahrzeugGeladen': + case 'SollwertGueltig': + case 'Verfuegbar': + case 'AenderungMoeglich': + case 'Stoerung': + $this->WriteAttributeBoolean('Zustand' . $ident, (bool) $wert); + break; + case 'Fahrzeugstatus': + case 'Phasenzahl': + case 'Sollleistung': + $this->WriteAttributeInteger('Zustand' . $ident, (int) $wert); + break; + case 'Ladestrom_A': + $ident = 'Ladestrom'; + $this->WriteAttributeFloat('ZustandLadestrom', (float) $wert); + break; + case 'Istleistung_W': + $ident = 'Istleistung'; + $this->WriteAttributeFloat('ZustandIstleistung', (float) $wert); + break; + case 'Stoertext': + $this->WriteAttributeString('ZustandStoertext', (string) $wert); + break; + default: + throw new LogicException('Unbekannter Zustand: ' . $ident); + } + $this->setzeSichtbareVariable($ident, $wert); + if ($ident === 'Istleistung') { + $this->setzeSichtbareVariable( + 'Leistungsquelle', + Nachrichtenvertrag::LEISTUNGSQUELLE_GEMESSEN + ); + } + } + + /** @return mixed */ + private function leseZustand(string $ident) + { + if ($ident === 'Ladefreigabe' || $ident === 'Solarladen') { + return $this->ReadAttributeBoolean('Zustand' . $ident); + } + if (in_array($ident, [ + 'FahrzeugVerbunden', 'FahrzeugGeladen', 'SollwertGueltig', + 'Verfuegbar', 'AenderungMoeglich', 'Stoerung', + ], true)) { + return $this->ReadAttributeBoolean('Zustand' . $ident); + } + if (in_array($ident, ['Fahrzeugstatus', 'Phasenzahl', 'Sollleistung'], true)) { + return $this->ReadAttributeInteger('Zustand' . $ident); + } + if ($ident === 'Ladestrom' || $ident === 'Istleistung') { + return $this->ReadAttributeFloat('Zustand' . $ident); + } + if ($ident === 'Stoertext') { + return $this->ReadAttributeString('ZustandStoertext'); + } + if ($ident === 'Leistungsquelle') { + return Nachrichtenvertrag::LEISTUNGSQUELLE_GEMESSEN; + } + throw new LogicException('Unbekannter Zustand: ' . $ident); + } + + /** @param mixed $wert */ + private function setzeSichtbareVariable(string $ident, $wert): void + { + $id = @$this->GetIDForIdent($ident); + if (is_int($id) && $id > 0 && IPS_VariableExists($id)) { + $this->SetValue($ident, $wert); + } + } + + /** @return list */ + private function leseLeistungsangebot(): array + { + $angebot = json_decode( + $this->ReadAttributeString('Leistungsangebot'), + true, + 512, + JSON_THROW_ON_ERROR + ); + + return is_array($angebot) ? array_map('intval', $angebot) : [0]; + } + + private function setzeStoerung(string $text): void + { + $this->setzeZustand('Stoerung', $text !== ''); + $this->setzeZustand('Stoertext', $text); + } + + private function deaktiviereTimer(): void + { + foreach (['Statuszyklus', 'Meldezyklus', 'RueckmeldungVerzoegert', 'VorgabeTimeout'] as $timer) { + $this->SetTimerInterval($timer, 0); + } + } + + private function protokolliere(string $bereich, string $nachricht): void + { + if (!$this->ReadPropertyBoolean('LoggingEin')) { + return; + } + $this->SendDebug($bereich, $nachricht, 0); + } +} diff --git a/Manager/module.php b/Manager/module.php index 0f4b685..de0206a 100644 --- a/Manager/module.php +++ b/Manager/module.php @@ -24,6 +24,8 @@ class Manager extends IPSModule implements ManagerSchnittstelle private const VERBRAUCHER_MODUL_IDS = [ '{B7C54AF4-AD7D-4FE4-B75D-203693906251}', '{15879A4E-D0C2-4495-83DE-46E1E462591E}', + '{0D94913C-0F31-4C29-A685-6EB4AE58E55D}', + '{C92D5EEF-9632-47A5-9659-4B02BF40FBE9}', ]; private const MONATSNAMEN = [ 1 => 'Januar', 'Februar', 'Maerz', 'April', 'Mai', 'Juni', @@ -357,25 +359,44 @@ class Manager extends IPSModule implements ManagerSchnittstelle return; } - $stoerungen = []; - $verbraucher = $this->aktuelleVerbraucher($stoerungen); $lastspitzengrenze = ManagerRegler::wirksameLastspitzengrenze( $this->ReadPropertyInteger('Lastspitzenmodus'), $this->ReadPropertyFloat('Lastspitzengrenze'), $this->dekodiereMonatsgrenzen($this->ReadPropertyString('Monatsgrenzen')), (int) date('n') ); - + $bisherigeBetriebsart = (string) $this->GetValue('Betriebsart'); $betriebsart = ManagerRegler::bestimmeBetriebsart( true, true, $netzleistung, - (string) $this->GetValue('Betriebsart'), + $bisherigeBetriebsart, $lastspitzengrenze !== null, $this->ReadPropertyFloat('SollwertSolarladen'), $lastspitzengrenze ?? 0.0, $this->ReadPropertyFloat('Umschaltdifferenz') ); + $this->SetValue('Betriebsart', $betriebsart); + if ($betriebsart !== $bisherigeBetriebsart) { + $this->WriteAttributeString('LetzteSollwerte', '{}'); + } + + $stoerungen = []; + $synchronisiert = true; + $verbraucher = $this->aktuelleVerbraucher($stoerungen, $betriebsart, $synchronisiert); + $this->setzeDiagnosewert('WirksameLastspitzengrenze', $lastspitzengrenze ?? 0.0); + if (!$synchronisiert) { + $this->setzeDiagnosewert('Verteilbudget', 0.0); + $this->setzeDiagnosewert('Verbraucherstatus', json_encode([ + 'Betriebsart' => $betriebsart, + 'Synchronisiert' => false, + 'Verbraucher' => $verbraucher, + ], JSON_THROW_ON_ERROR)); + $this->aktualisiereStoerungen($verbraucher, $stoerungen); + $this->SetStatus(self::STATUS_AKTIV); + return; + } + $zielnetzleistung = $betriebsart === ManagerRegler::BETRIEBSART_PEAK ? (float) $lastspitzengrenze : $this->ReadPropertyFloat('SollwertSolarladen'); @@ -385,12 +406,10 @@ class Manager extends IPSModule implements ManagerSchnittstelle $verbraucher, $betriebsart === ManagerRegler::BETRIEBSART_PEAK ? 'PrioritaetPeak' : 'PrioritaetPV' ); - - $this->SetValue('Betriebsart', $betriebsart); - $this->setzeDiagnosewert('WirksameLastspitzengrenze', $lastspitzengrenze ?? 0.0); $this->setzeDiagnosewert('Verteilbudget', (float) $ergebnis['Verteilbudget_W']); $this->setzeDiagnosewert('Verbraucherstatus', json_encode([ 'Betriebsart' => $betriebsart, + 'Synchronisiert' => true, 'Restabweichung_W' => $ergebnis['Restabweichung_W'], 'Verbraucher' => $verbraucher, ], JSON_THROW_ON_ERROR)); @@ -401,7 +420,7 @@ class Manager extends IPSModule implements ManagerSchnittstelle continue; } try { - $this->sendeSollwert((int) $instanzID, $sollleistung); + $this->sendeManagerdaten((int) $instanzID, $betriebsart, $sollleistung); $letzteSollwerte[(string) $instanzID] = $sollleistung; } catch (Throwable $fehler) { $stoerungen[] = 'Sollwert an Instanz ' . $instanzID . ': ' . $fehler->getMessage(); @@ -437,8 +456,11 @@ class Manager extends IPSModule implements ManagerSchnittstelle * @param list $stoerungen * @return list}> */ - private function aktuelleVerbraucher(array &$stoerungen): array - { + private function aktuelleVerbraucher( + array &$stoerungen, + string $betriebsart, + bool &$synchronisiert + ): array { $ids = $this->aktiveVerbraucherIDs(); $this->setzeDiagnosewert('VerbraucherAnzahl', count($ids)); $cache = $this->leseJsonAttribut('VerbraucherCache'); @@ -447,12 +469,23 @@ class Manager extends IPSModule implements ManagerSchnittstelle foreach ($ids as $instanzID) { $eintrag = $cache[(string) $instanzID] ?? null; + $grund = ''; if (!is_array($eintrag) || !isset($eintrag['EmpfangenAm'], $eintrag['Daten'])) { - $stoerungen[] = 'Noch keine Meldung von Instanz ' . $instanzID . '.'; - continue; + $grund = 'Noch keine Meldung'; + } elseif (time() - (int) $eintrag['EmpfangenAm'] > $timeout) { + $grund = 'Meldung ist veraltet'; + } elseif (($eintrag['Daten']['Betriebsart'] ?? null) !== $betriebsart) { + $grund = 'Leistungsangebot gehoert nicht zur Betriebsart ' . $betriebsart; } - if (time() - (int) $eintrag['EmpfangenAm'] > $timeout) { - $stoerungen[] = 'Meldung von Instanz ' . $instanzID . ' ist veraltet.'; + + if ($grund !== '') { + $synchronisiert = false; + $stoerungen[] = $grund . ' von Instanz ' . $instanzID . '.'; + try { + $this->sendeManagerdaten($instanzID, $betriebsart, null); + } catch (Throwable $fehler) { + $stoerungen[] = 'Betriebsart an Instanz ' . $instanzID . ': ' . $fehler->getMessage(); + } continue; } $aktuell[] = ['InstanzID' => $instanzID, 'Daten' => $eintrag['Daten']]; @@ -624,8 +657,11 @@ class Manager extends IPSModule implements ManagerSchnittstelle return false; } - private function sendeSollwert(int $instanzID, int $sollleistung): void - { + private function sendeManagerdaten( + int $instanzID, + string $betriebsart, + ?int $sollleistung + ): void { if (!IPS_InstanceExists($instanzID)) { throw new RuntimeException('Instanz existiert nicht.'); } @@ -636,11 +672,16 @@ class Manager extends IPSModule implements ManagerSchnittstelle 'EmpfaengerID' => $instanzID, 'Zeitpunkt' => time(), ], + 'Betriebsart' => $betriebsart, 'Sollleistung_W' => $sollleistung, ]; Nachrichtenvertrag::pruefeManagerdaten($paket); IPS_RequestAction($instanzID, 'ManagerdatenEmpfangen', json_encode($paket, JSON_THROW_ON_ERROR)); - $this->protokolliere('Sollwert', ['InstanzID' => $instanzID, 'Sollleistung_W' => $sollleistung]); + $this->protokolliere('Managerdaten', [ + 'InstanzID' => $instanzID, + 'Betriebsart' => $betriebsart, + 'Sollleistung_W' => $sollleistung, + ]); } /** diff --git a/Pufferspeicher/form.json b/Pufferspeicher/form.json new file mode 100644 index 0000000..5a5553a --- /dev/null +++ b/Pufferspeicher/form.json @@ -0,0 +1,163 @@ +{ + "elements": [ + { + "type": "ExpansionPanel", + "caption": "Manager und Zeitverhalten", + "expanded": true, + "items": [ + {"type": "NumberSpinner", "name": "PrioritaetPV", "caption": "Prioritaet PV", "minimum": 0}, + {"type": "NumberSpinner", "name": "PrioritaetPeak", "caption": "Prioritaet Peak", "minimum": 0}, + {"type": "NumberSpinner", "name": "Meldeintervall", "caption": "Meldeintervall", "suffix": " s", "minimum": 1}, + {"type": "NumberSpinner", "name": "VorgabeTimeout", "caption": "Vorgabe-Timeout", "suffix": " s", "minimum": 1}, + {"type": "NumberSpinner", "name": "LastwechselSperrzeit", "caption": "Lastwechsel-Sperrzeit", "suffix": " s", "minimum": 0} + ] + }, + { + "type": "ExpansionPanel", + "caption": "Pufferspeicher", + "expanded": true, + "items": [ + { + "type": "List", + "name": "LeistungsStufen", + "caption": "Leistungsstufen", + "add": true, + "delete": true, + "columns": [ + { + "caption": "Stufe", + "name": "Stufe", + "width": "100px", + "add": 1, + "edit": {"type": "NumberSpinner", "minimum": 1} + }, + { + "caption": "Leistung", + "name": "Leistung", + "width": "180px", + "add": 0, + "edit": {"type": "NumberSpinner", "minimum": 1, "suffix": " W"} + }, + { + "caption": "Schaltkontakt", + "name": "Schaltkontakt_Stufe", + "width": "300px", + "add": 0, + "edit": {"type": "SelectVariable", "validVariableTypes": [0]} + } + ] + }, + { + "type": "SelectVariable", + "name": "PufferfuehlerVariableID", + "caption": "Puffertemperatur", + "validVariableTypes": [1, 2] + }, + { + "type": "SelectVariable", + "name": "AussentemperaturVariableID", + "caption": "Aussentemperatur", + "validVariableTypes": [1, 2] + } + ] + }, + { + "type": "ExpansionPanel", + "caption": "Heizkurve", + "expanded": true, + "items": [ + { + "type": "NumberSpinner", + "name": "FusspunktVorlauftemperatur", + "caption": "Vorlauftemperatur bei 20 Grad C aussen", + "suffix": " Grad C", + "digits": 1 + }, + { + "type": "NumberSpinner", + "name": "HeizkurvenSteigung", + "caption": "Heizkurvensteigung", + "minimum": 0, + "digits": 2 + }, + { + "type": "NumberSpinner", + "name": "HeizkurveMinimaltemperatur", + "caption": "Untere Begrenzung der Solltemperatur", + "suffix": " Grad C", + "digits": 1 + }, + { + "type": "NumberSpinner", + "name": "HeizkurveMaximaltemperatur", + "caption": "Obere Begrenzung der Solltemperatur", + "suffix": " Grad C", + "digits": 1 + }, + { + "type": "NumberSpinner", + "name": "Hysterese", + "caption": "Hysterese", + "suffix": " K", + "minimum": 0.1, + "digits": 1 + }, + { + "type": "Select", + "name": "MindesttemperaturModus", + "caption": "Optionale Mindesttemperatur", + "options": [ + {"caption": "Aus", "value": 0}, + {"caption": "Statisch", "value": 1}, + {"caption": "Differenz zur Solltemperatur", "value": 2} + ] + }, + { + "type": "NumberSpinner", + "name": "Mindesttemperatur", + "caption": "Statische Mindesttemperatur", + "suffix": " Grad C", + "digits": 1 + }, + { + "type": "NumberSpinner", + "name": "MindesttemperaturDifferenz", + "caption": "Differenz zur Solltemperatur", + "suffix": " K", + "minimum": 0, + "digits": 1 + }, + { + "type": "SelectVariable", + "name": "WaermepumpenSolltemperaturVariableID", + "caption": "Optionale Solltemperatur der Waermepumpe", + "validVariableTypes": [1, 2] + }, + { + "type": "Button", + "name": "HeizkurveVonWaermepumpeUebernehmen", + "caption": "Wert von Waermepumpe uebernehmen", + "onClick": "IPS_RequestAction($id, 'HeizkurveVonWaermepumpeUebernehmen', 0);" + } + ] + }, + { + "type": "ExpansionPanel", + "caption": "Erweiterte Einstellungen", + "items": [ + {"type": "NumberSpinner", "name": "TemperaturMaxAlter", "caption": "Maximales Temperaturalter", "suffix": " s", "minimum": 1}, + {"type": "CheckBox", "name": "PuffertemperaturGlaetten", "caption": "Puffertemperatur glaetten"}, + {"type": "NumberSpinner", "name": "ZeitKonstante", "caption": "PT1-Zeitkonstante", "suffix": " s", "minimum": 1}, + {"type": "CheckBox", "name": "EinstellungenInVisu", "caption": "Einstellungen in der Visualisierung anzeigen"}, + {"type": "CheckBox", "name": "DiagnosevariablenAnzeigen", "caption": "Diagnosevariablen anzeigen"}, + {"type": "CheckBox", "name": "LoggingEin", "caption": "Diagnoseprotokoll aktivieren"} + ] + } + ], + "status": [ + {"code": 102, "icon": "active", "caption": "Aktiv"}, + {"code": 201, "icon": "error", "caption": "Temperaturmessung ungueltig"}, + {"code": 202, "icon": "error", "caption": "Konfiguration ungueltig"}, + {"code": 203, "icon": "error", "caption": "Schaltfehler"} + ] +} diff --git a/Pufferspeicher/module.json b/Pufferspeicher/module.json new file mode 100644 index 0000000..a83fe7e --- /dev/null +++ b/Pufferspeicher/module.json @@ -0,0 +1,14 @@ +{ + "id": "{C92D5EEF-9632-47A5-9659-4B02BF40FBE9}", + "name": "VerbraucherPufferspeicher", + "type": 3, + "vendor": "Enelix", + "aliases": [ + "Pufferspeicher" + ], + "parentRequirements": [], + "childRequirements": [], + "implemented": [], + "prefix": "ENELIX", + "url": "https://git.belevo.ch/ENELIX/Enelix-EMS/src/branch/develop/Pufferspeicher" +} diff --git a/Pufferspeicher/module.php b/Pufferspeicher/module.php new file mode 100644 index 0000000..548e09e --- /dev/null +++ b/Pufferspeicher/module.php @@ -0,0 +1,899 @@ + */ + private const DIAGNOSE_VARIABLEN = [ + 'Istleistung', + 'Leistungsquelle', + 'Sollleistung', + 'SollwertGueltig', + 'Verfuegbar', + 'AenderungMoeglich', + 'Stoerung', + 'Stoertext', + 'AktiveStufe', + 'BezogeneEnergie', + 'TemperaturenGueltig', + ]; + + public function Create(): void + { + parent::Create(); + + $this->registriereVerbraucherBasis(); + $this->RegisterPropertyString('LeistungsStufen', '[]'); + $this->RegisterPropertyInteger('PufferfuehlerVariableID', 0); + $this->RegisterPropertyInteger('AussentemperaturVariableID', 0); + $this->RegisterPropertyInteger('WaermepumpenSolltemperaturVariableID', 0); + $this->RegisterPropertyFloat('FusspunktVorlauftemperatur', 35.0); + $this->RegisterPropertyFloat('HeizkurvenSteigung', 1.0); + $this->RegisterPropertyFloat('HeizkurveMinimaltemperatur', 20.0); + $this->RegisterPropertyFloat('HeizkurveMaximaltemperatur', 80.0); + $this->RegisterPropertyFloat('Hysterese', 5.0); + $this->RegisterPropertyInteger('MindesttemperaturModus', PufferspeicherRegler::MINIMUM_AUS); + $this->RegisterPropertyFloat('Mindesttemperatur', 20.0); + $this->RegisterPropertyFloat('MindesttemperaturDifferenz', 5.0); + $this->RegisterPropertyInteger('LastwechselSperrzeit', 5); + $this->RegisterPropertyInteger('TemperaturMaxAlter', 120); + $this->RegisterPropertyBoolean('PuffertemperaturGlaetten', false); + $this->RegisterPropertyInteger('ZeitKonstante', 120); + $this->RegisterPropertyBoolean('DiagnosevariablenAnzeigen', false); + + $this->RegisterVariableFloat('Puffertemperatur', 'Puffertemperatur', '~Temperature', 100); + $this->RegisterVariableFloat('Aussentemperatur', 'Aussentemperatur', '~Temperature', 110); + $this->RegisterVariableFloat('Solltemperatur', 'Solltemperatur', '~Temperature', 120); + $this->RegisterVariableBoolean('Heizbedarf', 'Heizbedarf', '~Switch', 130); + + $this->RegisterAttributeInteger('RegistriertePufferID', 0); + $this->RegisterAttributeInteger('RegistrierteAussenID', 0); + $this->RegisterAttributeInteger('LetzteBerechnung', 0); + $this->RegisterAttributeInteger('LetzteTemperaturberechnung', 0); + $this->RegisterAttributeInteger('LetzteVorgabeZeit', 0); + $this->RegisterAttributeInteger('LetzterLastwechsel', 0); + $this->RegisterAttributeInteger('LetzteManagerID', 0); + $this->RegisterAttributeFloat('Glaettungswert', 0.0); + $this->RegisterAttributeBoolean('GlaettungInitialisiert', false); + $this->RegisterAttributeString('Leistungsangebot', '[0]'); + $this->RegisterAttributeString('Betriebsart', Nachrichtenvertrag::BETRIEBSART_PV); + $this->RegisterAttributeString('Schaltfehler', ''); + + $this->RegisterAttributeFloat('ZustandIstleistung', 0.0); + $this->RegisterAttributeInteger('ZustandSollleistung', 0); + $this->RegisterAttributeBoolean('ZustandSollwertGueltig', false); + $this->RegisterAttributeBoolean('ZustandVerfuegbar', false); + $this->RegisterAttributeBoolean('ZustandAenderungMoeglich', false); + $this->RegisterAttributeBoolean('ZustandStoerung', false); + $this->RegisterAttributeString('ZustandStoertext', ''); + $this->RegisterAttributeInteger('ZustandAktiveStufe', 0); + $this->RegisterAttributeFloat('ZustandBezogeneEnergie', 0.0); + $this->RegisterAttributeBoolean('ZustandTemperaturenGueltig', false); + + $this->RegisterTimer( + 'LastwechselFreigabe', + 0, + "IPS_RequestAction(\$_IPS['TARGET'], 'LastwechselFreigabe', false);" + ); + $this->RegisterTimer( + 'Meldezyklus', + 0, + "IPS_RequestAction(\$_IPS['TARGET'], 'Melden', false);" + ); + $this->RegisterTimer( + 'RueckmeldungVerzoegert', + 0, + "IPS_RequestAction(\$_IPS['TARGET'], 'Melden', true);" + ); + } + + public function ApplyChanges(): void + { + parent::ApplyChanges(); + + $this->aktualisiereVariablen(); + try { + $this->pruefeKonfiguration(); + } catch (Throwable $fehler) { + $this->deaktiviereTimer(); + $this->setzeZustand('Verfuegbar', false); + $this->setzeZustand('AenderungMoeglich', false); + $this->setzeZustand('Stoerung', true); + $this->setzeZustand('Stoertext', $fehler->getMessage()); + $this->SetStatus(self::STATUS_KONFIGURATION_UNGUELTIG); + $this->protokolliere('Konfiguration', $fehler->getMessage()); + return; + } + + $this->registriereTemperaturmeldungen(); + $this->SetTimerInterval('LastwechselFreigabe', 0); + $this->SetTimerInterval('Meldezyklus', $this->ReadPropertyInteger('Meldeintervall') * 1000); + $this->regelzyklus(true); + } + + public function MessageSink($zeitstempel, $senderID, $nachricht, $daten): void + { + if ((int) $nachricht !== self::VM_UPDATE) { + return; + } + if (in_array((int) $senderID, [ + $this->ReadPropertyInteger('PufferfuehlerVariableID'), + $this->ReadPropertyInteger('AussentemperaturVariableID'), + ], true)) { + $this->regelzyklus(true); + } + } + + public function RequestAction($ident, $wert): void + { + switch ($ident) { + case 'Aktiv': + $this->SetValue('Aktiv', (bool) $wert); + if (!(bool) $wert) { + $this->setzeZustand('SollwertGueltig', false); + } + $this->regelzyklus(true); + return; + + case 'HeizkurveVonWaermepumpeUebernehmen': + $this->uebernehmeWaermepumpenwert(); + return; + + case 'LastwechselFreigabe': + $this->SetTimerInterval('LastwechselFreigabe', 0); + $this->regelzyklus(true); + return; + + case 'Melden': + if ((bool) $wert) { + $this->SetTimerInterval('RueckmeldungVerzoegert', 0); + } + $this->regelzyklus(false); + $this->sendeVerbraucherdaten(); + return; + + case 'ManagerdatenEmpfangen': + if (!is_string($wert)) { + throw new InvalidArgumentException('Managerdaten muessen als JSON uebergeben werden.'); + } + $daten = json_decode($wert, true, 512, JSON_THROW_ON_ERROR); + if (!is_array($daten)) { + throw new InvalidArgumentException('Managerdaten muessen ein JSON-Objekt sein.'); + } + $this->ManagerdatenEmpfangen($daten); + return; + } + + throw new InvalidArgumentException('Unbekannte Aktion: ' . $ident); + } + + /** @param array $daten */ + public function ManagerdatenEmpfangen(array $daten): void + { + Nachrichtenvertrag::pruefeManagerdaten($daten); + if ($daten['Kopf']['EmpfaengerID'] !== $this->InstanceID) { + throw new InvalidArgumentException('Managerdaten sind an eine andere Instanz adressiert.'); + } + + $managerID = $daten['Kopf']['AbsenderID']; + if (!in_array($managerID, $this->zugeordneteManagerIDs(), true)) { + throw new InvalidArgumentException('Der Manager hat diesen Pufferspeicher nicht aktiv zugeordnet.'); + } + + $this->WriteAttributeString('Betriebsart', $daten['Betriebsart']); + $this->regelzyklus(false); + $angebot = $this->leseLeistungsangebot(); + $sollleistung = $daten['Sollleistung_W']; + if ($sollleistung === null) { + if ((bool) $this->leseZustand('SollwertGueltig') + && !in_array((int) $this->leseZustand('Sollleistung'), $angebot, true) + ) { + $this->setzeZustand('SollwertGueltig', false); + $this->regelzyklus(false); + } + $this->SetTimerInterval('RueckmeldungVerzoegert', 100); + return; + } + if (!in_array($sollleistung, $angebot, true)) { + throw new InvalidArgumentException('Sollleistung_W liegt nicht im aktuell gemeldeten Leistungsangebot.'); + } + + $this->WriteAttributeInteger('LetzteManagerID', $managerID); + $this->WriteAttributeInteger('LetzteVorgabeZeit', time()); + $this->setzeZustand('Sollleistung', $sollleistung); + $this->setzeZustand('SollwertGueltig', true); + $this->regelzyklus(false); + $this->SetTimerInterval('RueckmeldungVerzoegert', 100); + } + + + private function regelzyklus(bool $meldungPlanen): void + { + try { + $jetzt = time(); + $this->aktualisiereEnergie($jetzt); + $temperaturenGueltig = $this->aktualisiereTemperaturen(); + $stufen = PufferspeicherRegler::dekodiereLeistungsstufen( + $this->ReadPropertyString('LeistungsStufen') + ); + + $solltemperatur = 0.0; + $einschaltschwelle = 0.0; + if ($temperaturenGueltig) { + $solltemperatur = PufferspeicherRegler::solltemperatur( + (float) $this->GetValue('Aussentemperatur'), + $this->ReadPropertyFloat('FusspunktVorlauftemperatur'), + $this->ReadPropertyFloat('HeizkurvenSteigung'), + $this->ReadPropertyFloat('HeizkurveMinimaltemperatur'), + $this->ReadPropertyFloat('HeizkurveMaximaltemperatur') + ); + $einschaltschwelle = PufferspeicherRegler::einschaltschwelle( + $solltemperatur, + $this->ReadPropertyFloat('Hysterese'), + $this->ReadPropertyInteger('MindesttemperaturModus'), + $this->ReadPropertyFloat('Mindesttemperatur'), + $this->ReadPropertyFloat('MindesttemperaturDifferenz') + ); + } + $this->SetValue('Solltemperatur', $solltemperatur); + + $aktiv = (bool) $this->GetValue('Aktiv'); + $peak = $this->ReadAttributeString('Betriebsart') === Nachrichtenvertrag::BETRIEBSART_PEAK; + $angebot = PufferspeicherRegler::leistungsangebot( + $aktiv, + $peak, + $temperaturenGueltig, + (float) $this->GetValue('Puffertemperatur'), + $einschaltschwelle, + $stufen + ); + $heizbedarf = count($angebot) > 1; + $this->SetValue('Heizbedarf', $heizbedarf); + + $verfuegbar = $aktiv && $temperaturenGueltig && $stufen !== []; + $aenderungMoeglich = $verfuegbar && count($angebot) > 1; + $zwangsleistung = (!$verfuegbar || !$heizbedarf) ? 0 : null; + $aktuelleLeistung = (int) round((float) $this->leseZustand('Istleistung')); + $restzeit = PufferspeicherRegler::restzeitBisLastwechsel( + $this->ReadAttributeInteger('LetzterLastwechsel'), + $jetzt, + $this->ReadPropertyInteger('LastwechselSperrzeit') + ); + $lastwechselGesperrt = $restzeit > 0 && $verfuegbar && $zwangsleistung !== 0; + $this->SetTimerInterval('LastwechselFreigabe', $lastwechselGesperrt ? $restzeit * 1000 : 0); + if ($lastwechselGesperrt) { + $angebot = [$aktuelleLeistung]; + $aenderungMoeglich = false; + } + + $letzteVorgabe = $this->ReadAttributeInteger('LetzteVorgabeZeit'); + if ($letzteVorgabe <= 0 + || $jetzt - $letzteVorgabe > $this->ReadPropertyInteger('VorgabeTimeout') + ) { + $this->setzeZustand('SollwertGueltig', false); + } + + $ziel = 0; + if ($lastwechselGesperrt) { + $ziel = $aktuelleLeistung; + } elseif ($zwangsleistung !== null) { + $ziel = $zwangsleistung; + } elseif ((bool) $this->leseZustand('SollwertGueltig')) { + $ziel = (int) $this->leseZustand('Sollleistung'); + if (!in_array($ziel, $angebot, true)) { + $this->setzeZustand('SollwertGueltig', false); + $ziel = 0; + } + } + + $lastwechsel = $this->schalteLeistung($ziel, $jetzt); + if ($lastwechsel) { + $angebot = [$ziel]; + $aenderungMoeglich = false; + } + $this->WriteAttributeString('Leistungsangebot', json_encode($angebot, JSON_THROW_ON_ERROR)); + $this->setzeZustand('Sollleistung', $ziel); + $this->setzeZustand('Verfuegbar', $verfuegbar); + $this->setzeZustand('AenderungMoeglich', $aenderungMoeglich); + $this->aktualisiereStoerungen($temperaturenGueltig); + if ($meldungPlanen) { + $this->SetTimerInterval('RueckmeldungVerzoegert', 100); + } + } catch (Throwable $fehler) { + $this->WriteAttributeString('Schaltfehler', $fehler->getMessage()); + $this->setzeZustand('Verfuegbar', false); + $this->setzeZustand('AenderungMoeglich', false); + $this->setzeZustand('Stoerung', true); + $this->setzeZustand('Stoertext', $fehler->getMessage()); + $this->SetStatus(self::STATUS_SCHALTFEHLER); + $this->protokolliere('Regelzyklus', $fehler->getMessage()); + } + } + + private function aktualisiereTemperaturen(): bool + { + $pufferID = $this->ReadPropertyInteger('PufferfuehlerVariableID'); + $aussenID = $this->ReadPropertyInteger('AussentemperaturVariableID'); + if (!$this->temperaturwertGueltig($pufferID) || !$this->temperaturwertGueltig($aussenID)) { + $this->setzeZustand('TemperaturenGueltig', false); + return false; + } + + $puffertemperatur = (float) GetValue($pufferID); + $aussentemperatur = (float) GetValue($aussenID); + $jetzt = time(); + $letzteBerechnung = $this->ReadAttributeInteger('LetzteTemperaturberechnung'); + $this->WriteAttributeInteger('LetzteTemperaturberechnung', $jetzt); + if ($this->ReadPropertyBoolean('PuffertemperaturGlaetten')) { + if (!$this->ReadAttributeBoolean('GlaettungInitialisiert')) { + $this->WriteAttributeFloat('Glaettungswert', $puffertemperatur); + $this->WriteAttributeBoolean('GlaettungInitialisiert', true); + } elseif ($letzteBerechnung > 0 && $jetzt > $letzteBerechnung) { + $puffertemperatur = PufferspeicherRegler::pt1( + $this->ReadAttributeFloat('Glaettungswert'), + $puffertemperatur, + $jetzt - $letzteBerechnung, + $this->ReadPropertyInteger('ZeitKonstante') + ); + $this->WriteAttributeFloat('Glaettungswert', $puffertemperatur); + } else { + $puffertemperatur = $this->ReadAttributeFloat('Glaettungswert'); + } + } else { + $this->WriteAttributeBoolean('GlaettungInitialisiert', false); + } + + $this->SetValue('Puffertemperatur', $puffertemperatur); + $this->SetValue('Aussentemperatur', $aussentemperatur); + $this->setzeZustand('TemperaturenGueltig', true); + + return true; + } + + private function temperaturwertGueltig(int $variablenID): bool + { + if ($variablenID <= 0 || !IPS_VariableExists($variablenID)) { + return false; + } + $variable = IPS_GetVariable($variablenID); + if (!in_array((int) $variable['VariableType'], [1, 2], true)) { + return false; + } + if (time() - (int) $variable['VariableUpdated'] > $this->ReadPropertyInteger('TemperaturMaxAlter')) { + return false; + } + $wert = GetValue($variablenID); + + return is_int($wert) || is_float($wert); + } + + private function uebernehmeWaermepumpenwert(): void + { + $wpID = $this->ReadPropertyInteger('WaermepumpenSolltemperaturVariableID'); + $aussenID = $this->ReadPropertyInteger('AussentemperaturVariableID'); + if (!$this->temperaturwertGueltig($wpID) || !$this->temperaturwertGueltig($aussenID)) { + throw new RuntimeException('Waermepumpen-Solltemperatur oder Aussentemperatur ist ungueltig.'); + } + $fusspunkt = PufferspeicherRegler::fusspunktAusWaermepumpenwert( + (float) GetValue($wpID), + (float) GetValue($aussenID), + $this->ReadPropertyFloat('HeizkurvenSteigung') + ); + IPS_SetProperty($this->InstanceID, 'FusspunktVorlauftemperatur', $fusspunkt); + IPS_ApplyChanges($this->InstanceID); + $this->protokolliere('Heizkurve', ['FusspunktVorlauftemperatur' => $fusspunkt]); + } + + private function schalteLeistung(int $zielLeistung, int $jetzt): bool + { + $stufen = PufferspeicherRegler::dekodiereLeistungsstufen( + $this->ReadPropertyString('LeistungsStufen') + ); + $zielStufe = PufferspeicherRegler::stufeFuerLeistung($stufen, $zielLeistung); + $bisher = (int) round((float) $this->leseZustand('Istleistung')); + if ($zielLeistung === $bisher && $this->kontakteEntsprechenZiel($stufen, $zielLeistung)) { + $this->WriteAttributeString('Schaltfehler', ''); + return false; + } + + try { + foreach ($stufen as $stufe) { + RequestAction($stufe['KontaktID'], false); + } + foreach ($stufen as $stufe) { + if ($stufe['Leistung_W'] === $zielLeistung && $zielLeistung > 0) { + RequestAction($stufe['KontaktID'], true); + break; + } + } + } catch (Throwable $fehler) { + foreach ($stufen as $stufe) { + try { + RequestAction($stufe['KontaktID'], false); + } catch (Throwable $ignoriert) { + } + } + $this->setzeZustand('Istleistung', 0.0); + $this->setzeZustand('AktiveStufe', 0); + throw new RuntimeException('Leistungsstufe konnte nicht sicher geschaltet werden: ' . $fehler->getMessage()); + } + + $this->WriteAttributeString('Schaltfehler', ''); + $this->setzeZustand('Istleistung', (float) $zielLeistung); + $this->setzeZustand('Leistungsquelle', Nachrichtenvertrag::LEISTUNGSQUELLE_BERECHNET); + $this->setzeZustand('AktiveStufe', $zielStufe); + $this->WriteAttributeInteger('LetzterLastwechsel', $jetzt); + $this->protokolliere('Leistungsstufe', ['Leistung_W' => $zielLeistung, 'Stufe' => $zielStufe]); + + return true; + } + + /** @param list $stufen */ + private function kontakteEntsprechenZiel(array $stufen, int $zielLeistung): bool + { + foreach ($stufen as $stufe) { + if (!IPS_VariableExists($stufe['KontaktID'])) { + return false; + } + $sollEin = $zielLeistung > 0 && $stufe['Leistung_W'] === $zielLeistung; + if ((bool) GetValue($stufe['KontaktID']) !== $sollEin) { + return false; + } + } + + return true; + } + + private function aktualisiereEnergie(int $jetzt): void + { + $letzteBerechnung = $this->ReadAttributeInteger('LetzteBerechnung'); + $this->WriteAttributeInteger('LetzteBerechnung', $jetzt); + if ($letzteBerechnung <= 0 || $jetzt <= $letzteBerechnung) { + return; + } + $zuwachs = PufferspeicherRegler::energieKWh( + (float) $this->leseZustand('Istleistung'), + $jetzt - $letzteBerechnung + ); + $this->setzeZustand( + 'BezogeneEnergie', + (float) $this->leseZustand('BezogeneEnergie') + $zuwachs + ); + } + + + private function aktualisiereStoerungen(bool $temperaturenGueltig): void + { + $stoerungen = []; + if (!$temperaturenGueltig) { + $stoerungen[] = 'Puffer- oder Aussentemperatur fehlt oder ist veraltet.'; + } + if ($this->ReadAttributeString('Schaltfehler') !== '') { + $stoerungen[] = $this->ReadAttributeString('Schaltfehler'); + } + $this->setzeZustand('Stoerung', $stoerungen !== []); + $this->setzeZustand('Stoertext', implode(" +", array_values(array_unique($stoerungen)))); + if ($this->ReadAttributeString('Schaltfehler') !== '') { + $this->SetStatus(self::STATUS_SCHALTFEHLER); + } elseif (!$temperaturenGueltig) { + $this->SetStatus(self::STATUS_TEMPERATUR_UNGUELTIG); + } else { + $this->SetStatus(self::STATUS_AKTIV); + } + } + + private function sendeVerbraucherdaten(): void + { + foreach ($this->zugeordneteManagerIDs() as $managerID) { + $daten = $this->baueVerbraucherdaten($managerID); + Nachrichtenvertrag::pruefeVerbraucherdaten($daten); + try { + IPS_RequestAction( + $managerID, + 'VerbraucherdatenEmpfangen', + json_encode($daten, JSON_THROW_ON_ERROR) + ); + } catch (Throwable $fehler) { + $this->protokolliere('Managerkommunikation', $fehler->getMessage()); + } + } + } + + /** @return array */ + private function baueVerbraucherdaten(int $managerID): array + { + $temperaturenGueltig = (bool) $this->leseZustand('TemperaturenGueltig'); + $schaltfehler = $this->ReadAttributeString('Schaltfehler'); + + return [ + 'Kopf' => [ + 'Version' => Nachrichtenvertrag::VERSION, + 'AbsenderID' => $this->InstanceID, + 'EmpfaengerID' => $managerID, + 'Zeitpunkt' => time(), + ], + 'Betriebsart' => $this->ReadAttributeString('Betriebsart'), + 'PrioritaetPV' => $this->ReadPropertyInteger('PrioritaetPV'), + 'PrioritaetPeak' => $this->ReadPropertyInteger('PrioritaetPeak'), + 'Leistungswerte_W' => $this->leseLeistungsangebot(), + 'AenderungMoeglich' => (bool) $this->leseZustand('AenderungMoeglich'), + 'Verfuegbar' => (bool) $this->leseZustand('Verfuegbar'), + 'Istleistung_W' => (float) $this->leseZustand('Istleistung'), + 'Leistungsquelle' => Nachrichtenvertrag::LEISTUNGSQUELLE_BERECHNET, + 'Zustand' => [ + [ + 'Kennung' => 'Sollleistung_W', + 'Art' => 'Sollwert', + 'Wert' => (bool) $this->leseZustand('SollwertGueltig') + ? (int) $this->leseZustand('Sollleistung') + : null, + 'Einheit' => 'W', + ], + [ + 'Kennung' => 'Puffertemperatur_C', + 'Art' => 'Istwert', + 'Wert' => $temperaturenGueltig ? (float) $this->GetValue('Puffertemperatur') : null, + 'Einheit' => 'C', + ], + [ + 'Kennung' => 'Aussentemperatur_C', + 'Art' => 'Istwert', + 'Wert' => $temperaturenGueltig ? (float) $this->GetValue('Aussentemperatur') : null, + 'Einheit' => 'C', + ], + [ + 'Kennung' => 'Solltemperatur_C', + 'Art' => 'Sollwert', + 'Wert' => $temperaturenGueltig ? (float) $this->GetValue('Solltemperatur') : null, + 'Einheit' => 'C', + ], + [ + 'Kennung' => 'Heizbedarf', + 'Art' => 'Status', + 'Wert' => (bool) $this->GetValue('Heizbedarf'), + 'Einheit' => '', + ], + [ + 'Kennung' => 'AktiveStufe', + 'Art' => 'Status', + 'Wert' => (int) $this->leseZustand('AktiveStufe'), + 'Einheit' => '', + ], + [ + 'Kennung' => 'Fuehlerfehler', + 'Art' => 'Stoerung', + 'Wert' => !$temperaturenGueltig, + 'Einheit' => '', + 'Text' => $temperaturenGueltig + ? '' + : 'Puffer- oder Aussentemperatur fehlt oder ist veraltet.', + ], + [ + 'Kennung' => 'Schaltfehler', + 'Art' => 'Stoerung', + 'Wert' => $schaltfehler !== '', + 'Einheit' => '', + 'Text' => $schaltfehler, + ], + ], + ]; + } + + /** @return list */ + private function zugeordneteManagerIDs(): array + { + $ergebnis = []; + foreach (IPS_GetInstanceListByModuleID(self::MANAGER_MODULE_ID) as $managerID) { + try { + $automatisch = (bool) IPS_GetProperty($managerID, 'AutomatischeSuche'); + $property = $automatisch + ? 'AutomatischeVerbraucherZuordnung' + : 'VerbraucherZuordnung'; + $zuordnung = $this->leseManagerZuordnung($managerID, $property); + if ($automatisch && $zuordnung === []) { + $zuordnung = $this->leseManagerZuordnung($managerID, 'VerbraucherZuordnung'); + } + } catch (Throwable $fehler) { + continue; + } + + foreach ($zuordnung as $eintrag) { + if (!is_array($eintrag) || ($eintrag['Aktiv'] ?? false) !== true) { + continue; + } + $instanzID = $eintrag['InstanzID'] ?? $eintrag['Verbraucher'] ?? null; + if ($instanzID === $this->InstanceID) { + $ergebnis[] = (int) $managerID; + break; + } + } + } + + return array_values(array_unique($ergebnis)); + } + + /** @return array */ + private function leseManagerZuordnung(int $managerID, string $property): array + { + $zuordnung = json_decode( + (string) IPS_GetProperty($managerID, $property), + true, + 512, + JSON_THROW_ON_ERROR + ); + + return is_array($zuordnung) ? $zuordnung : []; + } + + private function pruefeKonfiguration(): void + { + foreach (['PrioritaetPV', 'PrioritaetPeak'] as $property) { + if ($this->ReadPropertyInteger($property) < 0) { + throw new InvalidArgumentException($property . ' muss mindestens 0 sein.'); + } + } + foreach ([ + 'Meldeintervall', + 'VorgabeTimeout', + 'TemperaturMaxAlter', + 'ZeitKonstante', + ] as $property) { + if ($this->ReadPropertyInteger($property) <= 0) { + throw new InvalidArgumentException($property . ' muss groesser als 0 sein.'); + } + } + if ($this->ReadPropertyInteger('LastwechselSperrzeit') < 0) { + throw new InvalidArgumentException('LastwechselSperrzeit darf nicht negativ sein.'); + } + if ($this->ReadPropertyFloat('Hysterese') <= 0.0) { + throw new InvalidArgumentException('Hysterese muss groesser als 0 sein.'); + } + + PufferspeicherRegler::solltemperatur( + 20.0, + $this->ReadPropertyFloat('FusspunktVorlauftemperatur'), + $this->ReadPropertyFloat('HeizkurvenSteigung'), + $this->ReadPropertyFloat('HeizkurveMinimaltemperatur'), + $this->ReadPropertyFloat('HeizkurveMaximaltemperatur') + ); + PufferspeicherRegler::einschaltschwelle( + 50.0, + $this->ReadPropertyFloat('Hysterese'), + $this->ReadPropertyInteger('MindesttemperaturModus'), + $this->ReadPropertyFloat('Mindesttemperatur'), + $this->ReadPropertyFloat('MindesttemperaturDifferenz') + ); + + foreach (['PufferfuehlerVariableID', 'AussentemperaturVariableID'] as $property) { + $variablenID = $this->ReadPropertyInteger($property); + if ($variablenID <= 0 || !IPS_VariableExists($variablenID)) { + throw new InvalidArgumentException($property . ' ist nicht eingerichtet.'); + } + $variable = IPS_GetVariable($variablenID); + if (!in_array((int) $variable['VariableType'], [1, 2], true)) { + throw new InvalidArgumentException($property . ' muss eine Integer- oder Floatvariable sein.'); + } + } + + $wpID = $this->ReadPropertyInteger('WaermepumpenSolltemperaturVariableID'); + if ($wpID > 0) { + if (!IPS_VariableExists($wpID)) { + throw new InvalidArgumentException('WaermepumpenSolltemperaturVariableID existiert nicht.'); + } + $wpVariable = IPS_GetVariable($wpID); + if (!in_array((int) $wpVariable['VariableType'], [1, 2], true)) { + throw new InvalidArgumentException( + 'WaermepumpenSolltemperaturVariableID muss eine Integer- oder Floatvariable sein.' + ); + } + } + + $stufen = PufferspeicherRegler::dekodiereLeistungsstufen( + $this->ReadPropertyString('LeistungsStufen') + ); + if ($stufen === []) { + throw new InvalidArgumentException('Mindestens eine Leistungsstufe ist erforderlich.'); + } + foreach ($stufen as $stufe) { + if (!IPS_VariableExists($stufe['KontaktID'])) { + throw new InvalidArgumentException('Schaltkontakt ' . $stufe['KontaktID'] . ' existiert nicht.'); + } + $kontakt = IPS_GetVariable($stufe['KontaktID']); + if ((int) $kontakt['VariableType'] !== 0) { + throw new InvalidArgumentException('Alle Schaltkontakte muessen Booleanvariablen sein.'); + } + } + } + + + private function registriereTemperaturmeldungen(): void + { + $zuordnungen = [ + 'RegistriertePufferID' => $this->ReadPropertyInteger('PufferfuehlerVariableID'), + 'RegistrierteAussenID' => $this->ReadPropertyInteger('AussentemperaturVariableID'), + ]; + foreach ($zuordnungen as $attribut => $neueID) { + $alteID = $this->ReadAttributeInteger($attribut); + if ($alteID > 0 && $alteID !== $neueID) { + $this->UnregisterMessage($alteID, self::VM_UPDATE); + } + if ($neueID > 0 && $neueID !== $alteID) { + $this->RegisterMessage($neueID, self::VM_UPDATE); + } + $this->WriteAttributeInteger($attribut, $neueID); + } + } + + private function aktualisiereVariablen(): void + { + foreach (self::DIAGNOSE_VARIABLEN as $ident) { + if ($ident === 'Leistungsquelle') { + continue; + } + $variablenID = @IPS_GetObjectIDByIdent($ident, $this->InstanceID); + if (is_int($variablenID) && $variablenID > 0 && IPS_VariableExists($variablenID)) { + $this->setzeZustand($ident, GetValue($variablenID)); + } + } + + if ($this->ReadPropertyBoolean('DiagnosevariablenAnzeigen')) { + $this->registriereVerbraucherDiagnose(); + $this->RegisterVariableInteger('AktiveStufe', 'Aktive Stufe', '', 200); + $this->RegisterVariableFloat('BezogeneEnergie', 'Bezogene Energie', '~Electricity', 210); + $this->RegisterVariableBoolean( + 'TemperaturenGueltig', + 'Temperaturen gueltig', + '~Switch', + 220 + ); + foreach (self::DIAGNOSE_VARIABLEN as $ident) { + $wert = $ident === 'Leistungsquelle' + ? Nachrichtenvertrag::LEISTUNGSQUELLE_BERECHNET + : $this->leseZustand($ident); + $this->setzeZustand($ident, $wert); + } + } else { + foreach (self::DIAGNOSE_VARIABLEN as $ident) { + $variablenID = @IPS_GetObjectIDByIdent($ident, $this->InstanceID); + if (is_int($variablenID) && $variablenID > 0 && IPS_VariableExists($variablenID)) { + $this->UnregisterVariable($ident); + } + } + } + } + + /** @param bool|int|float|string $wert */ + private function setzeZustand(string $ident, $wert): void + { + $booleanAttribute = [ + 'SollwertGueltig' => 'ZustandSollwertGueltig', + 'Verfuegbar' => 'ZustandVerfuegbar', + 'AenderungMoeglich' => 'ZustandAenderungMoeglich', + 'Stoerung' => 'ZustandStoerung', + 'TemperaturenGueltig' => 'ZustandTemperaturenGueltig', + ]; + $integerAttribute = [ + 'Sollleistung' => 'ZustandSollleistung', + 'AktiveStufe' => 'ZustandAktiveStufe', + ]; + $floatAttribute = [ + 'Istleistung' => 'ZustandIstleistung', + 'BezogeneEnergie' => 'ZustandBezogeneEnergie', + ]; + $stringAttribute = ['Stoertext' => 'ZustandStoertext']; + + if (isset($booleanAttribute[$ident])) { + $wert = (bool) $wert; + $this->WriteAttributeBoolean($booleanAttribute[$ident], $wert); + } elseif (isset($integerAttribute[$ident])) { + $wert = (int) $wert; + $this->WriteAttributeInteger($integerAttribute[$ident], $wert); + } elseif (isset($floatAttribute[$ident])) { + $wert = (float) $wert; + $this->WriteAttributeFloat($floatAttribute[$ident], $wert); + } elseif (isset($stringAttribute[$ident])) { + $wert = (string) $wert; + $this->WriteAttributeString($stringAttribute[$ident], $wert); + } elseif ($ident !== 'Leistungsquelle') { + throw new LogicException('Unbekannter interner Zustand: ' . $ident); + } + + $variablenID = @IPS_GetObjectIDByIdent($ident, $this->InstanceID); + if (is_int($variablenID) && $variablenID > 0 && IPS_VariableExists($variablenID)) { + SetValue($variablenID, $wert); + } + } + + /** @return bool|int|float|string */ + private function leseZustand(string $ident) + { + $booleanAttribute = [ + 'SollwertGueltig' => 'ZustandSollwertGueltig', + 'Verfuegbar' => 'ZustandVerfuegbar', + 'AenderungMoeglich' => 'ZustandAenderungMoeglich', + 'Stoerung' => 'ZustandStoerung', + 'TemperaturenGueltig' => 'ZustandTemperaturenGueltig', + ]; + $integerAttribute = [ + 'Sollleistung' => 'ZustandSollleistung', + 'AktiveStufe' => 'ZustandAktiveStufe', + ]; + $floatAttribute = [ + 'Istleistung' => 'ZustandIstleistung', + 'BezogeneEnergie' => 'ZustandBezogeneEnergie', + ]; + $stringAttribute = ['Stoertext' => 'ZustandStoertext']; + + if (isset($booleanAttribute[$ident])) { + return $this->ReadAttributeBoolean($booleanAttribute[$ident]); + } + if (isset($integerAttribute[$ident])) { + return $this->ReadAttributeInteger($integerAttribute[$ident]); + } + if (isset($floatAttribute[$ident])) { + return $this->ReadAttributeFloat($floatAttribute[$ident]); + } + if (isset($stringAttribute[$ident])) { + return $this->ReadAttributeString($stringAttribute[$ident]); + } + + throw new LogicException('Unbekannter interner Zustand: ' . $ident); + } + + /** @return list */ + private function leseLeistungsangebot(): array + { + $angebot = json_decode( + $this->ReadAttributeString('Leistungsangebot'), + true, + 512, + JSON_THROW_ON_ERROR + ); + if (!is_array($angebot)) { + return [0]; + } + + return array_values(array_map('intval', $angebot)); + } + + private function deaktiviereTimer(): void + { + foreach (['LastwechselFreigabe', 'Meldezyklus', 'RueckmeldungVerzoegert'] as $timer) { + $this->SetTimerInterval($timer, 0); + } + } + + /** @param mixed $daten */ + private function protokolliere(string $bezeichnung, $daten): void + { + if (!$this->ReadPropertyBoolean('LoggingEin')) { + return; + } + $this->SendDebug( + $bezeichnung, + is_string($daten) ? $daten : json_encode($daten, JSON_THROW_ON_ERROR), + 0 + ); + } +} diff --git a/README.md b/README.md index 380ce17..b19175a 100644 --- a/README.md +++ b/README.md @@ -5,9 +5,9 @@ Energiemanagement, Manager und steuerbare Verbraucher fuer IP-Symcon. ## Status Das Repository befindet sich im Aufbau. Der gemeinsame Nachrichtenvertrag, die -Verbraucherbasis, der Manager, der mehrstufige Warmwassererwaermer und der -1-stufige Verbraucher sind implementiert. Die weiteren Module sind als -Diskussionsentwürfe dokumentiert. +Verbraucherbasis, der Manager, der mehrstufige Warmwassererwaermer, der +1-stufige Verbraucher und die Ladestation Stand-Alone sind implementiert. Die +weiteren Module sind als Diskussionsentwürfe dokumentiert. ## Geplante Module @@ -17,7 +17,7 @@ Diskussionsentwürfe dokumentiert. - Pufferspeicher - Verbraucher 1-Stufig (implementiert) - Waermepumpe -- Ladestation Stand-Alone +- Ladestation Stand-Alone (implementiert) - Ladestation Gateway - Easee Gateway @@ -65,6 +65,7 @@ der Uebernahme in einen Freigabebranch erfolgreich sein. - [Manager-Modul](docs/module/Manager/README.md) - [Warmwassererwaermer-Modul](docs/module/Wassererwaermer/README.md) - [Verbraucher-1-Stufig-Modul](docs/module/Verbraucher-1-Stufig/README.md) +- [Ladestation-Stand-Alone-Modul](docs/module/Ladestation-Stand-Alone/README.md) - [Migration Boiler x-Stufig](docs/migration/Boiler-x-Stufig.md) - [Migration Verbraucher 1-Stufig](docs/migration/Verbraucher-1-Stufig.md) - [Obere Anschlüsse des Managers](docs/Obere-Anschluesse.md) diff --git a/VerbraucherEinStufig/form.json b/VerbraucherEinStufig/form.json index a6160bd..d62d241 100644 --- a/VerbraucherEinStufig/form.json +++ b/VerbraucherEinStufig/form.json @@ -87,6 +87,11 @@ "suffix": " s", "minimum": 0, "maximum": 86400 + }, + { + "type": "CheckBox", + "name": "PeakSperreBeiMindestlaufzeitAnbieten", + "caption": "Bei Peak Sperre waehrend der Mindestlaufzeit anbieten" } ] }, diff --git a/VerbraucherEinStufig/module.php b/VerbraucherEinStufig/module.php index 258ffd2..72193ca 100644 --- a/VerbraucherEinStufig/module.php +++ b/VerbraucherEinStufig/module.php @@ -47,6 +47,7 @@ class VerbraucherEinStufig extends IPSModule implements VerbraucherSchnittstelle $this->RegisterPropertyBoolean('SchaltkontaktInvertiert', false); $this->RegisterPropertyInteger('RueckmeldungVariableID', 0); $this->RegisterPropertyInteger('Mindestlaufzeit', 0); + $this->RegisterPropertyBoolean('PeakSperreBeiMindestlaufzeitAnbieten', true); $this->RegisterPropertyInteger('Mindesteinschaltdauer', 5); $this->RegisterPropertyInteger('Mindestausschaltdauer', 5); $this->RegisterPropertyBoolean('DiagnosevariablenAnzeigen', false); @@ -63,6 +64,7 @@ class VerbraucherEinStufig extends IPSModule implements VerbraucherSchnittstelle $this->RegisterAttributeInteger('LaufzeitStandZeit', 0); $this->RegisterAttributeString('LaufzeitTag', ''); $this->RegisterAttributeString('Leistungsangebot', '[0]'); + $this->RegisterAttributeString('Betriebsart', Nachrichtenvertrag::BETRIEBSART_PV); $this->RegisterAttributeBoolean('Initialisiert', false); $this->RegisterAttributeBoolean('SchaltvorgangLaeuft', false); $this->RegisterAttributeBoolean('SchaltpruefungAusstehend', false); @@ -205,8 +207,18 @@ class VerbraucherEinStufig extends IPSModule implements VerbraucherSchnittstelle throw new InvalidArgumentException('Der Manager hat diesen Verbraucher nicht aktiv zugeordnet.'); } + $this->WriteAttributeString('Betriebsart', $daten['Betriebsart']); $this->aktualisiere(false); $sollleistung = $daten['Sollleistung_W']; + if ($sollleistung === null) { + if ((bool) $this->leseZustand('SollwertGueltig') + && !in_array((int) $this->leseZustand('Sollleistung'), $this->leseLeistungsangebot(), true) + ) { + $this->setzeZustand('SollwertGueltig', false); + } + $this->planeMeldung(); + return; + } if (!in_array($sollleistung, $this->leseLeistungsangebot(), true)) { throw new InvalidArgumentException( 'Sollleistung_W liegt nicht im aktuell gemeldeten Leistungsangebot.' @@ -264,9 +276,16 @@ class VerbraucherEinStufig extends IPSModule implements VerbraucherSchnittstelle $jetzt ); + $peakbetrieb = $this->ReadAttributeString('Betriebsart') + === Nachrichtenvertrag::BETRIEBSART_PEAK; + $peakSperreBeiMindestlaufzeitAnbieten = $this->ReadPropertyBoolean( + 'PeakSperreBeiMindestlaufzeitAnbieten' + ); + $mindestlaufzeitLokalErzwingen = $mindestlaufzeitErzwungen + && (!$peakbetrieb || !$peakSperreBeiMindestlaufzeitAnbieten); $ziel = 0; if ($verfuegbar) { - if ($mindestlaufzeitErzwungen) { + if ($mindestlaufzeitLokalErzwingen) { $ziel = $this->ReadPropertyInteger('Nennleistung'); } elseif ((bool) $this->leseZustand('SollwertGueltig')) { $ziel = (int) $this->leseZustand('Sollleistung'); @@ -302,7 +321,8 @@ class VerbraucherEinStufig extends IPSModule implements VerbraucherSchnittstelle $this->setzeZustand('SchaltbefehlAusstehend', $wartetAufRueckmeldung); $this->setzeZustand('RestMindestzeit', $restMindestzeit); $aenderungMoeglich = $verfuegbar - && !$mindestlaufzeitErzwungen + && (!$mindestlaufzeitErzwungen + || ($peakbetrieb && $peakSperreBeiMindestlaufzeitAnbieten)) && $restMindestzeit === 0 && !$wartetAufRueckmeldung; $this->setzeZustand('AenderungMoeglich', $aenderungMoeglich); @@ -312,7 +332,9 @@ class VerbraucherEinStufig extends IPSModule implements VerbraucherSchnittstelle $istleistung, $verfuegbar, $aenderungMoeglich, - $mindestlaufzeitErzwungen + $mindestlaufzeitErzwungen, + $peakbetrieb, + $peakSperreBeiMindestlaufzeitAnbieten ); $this->WriteAttributeString('Leistungsangebot', json_encode($angebot, JSON_THROW_ON_ERROR)); $this->aktualisiereStoerung(); @@ -599,6 +621,7 @@ class VerbraucherEinStufig extends IPSModule implements VerbraucherSchnittstelle 'EmpfaengerID' => $managerID, 'Zeitpunkt' => time(), ], + 'Betriebsart' => $this->ReadAttributeString('Betriebsart'), 'PrioritaetPV' => $this->ReadPropertyInteger('PrioritaetPV'), 'PrioritaetPeak' => $this->ReadPropertyInteger('PrioritaetPeak'), 'Leistungswerte_W' => $this->leseLeistungsangebot(), diff --git a/Warmwassererwaermer/module.php b/Warmwassererwaermer/module.php index cb40eab..9a2b040 100644 --- a/Warmwassererwaermer/module.php +++ b/Warmwassererwaermer/module.php @@ -79,6 +79,7 @@ class VerbraucherWarmwassererwaermer extends IPSModule implements VerbraucherSch $this->RegisterAttributeFloat('Glaettungswert', 0.0); $this->RegisterAttributeBoolean('GlaettungInitialisiert', false); $this->RegisterAttributeString('Leistungsangebot', '[]'); + $this->RegisterAttributeString('Betriebsart', Nachrichtenvertrag::BETRIEBSART_PV); $this->RegisterAttributeString('Schaltfehler', ''); $this->RegisterAttributeFloat('ZustandIstleistung', 0.0); $this->RegisterAttributeInteger('ZustandSollleistung', 0); @@ -208,9 +209,20 @@ class VerbraucherWarmwassererwaermer extends IPSModule implements VerbraucherSch throw new InvalidArgumentException('Der Manager hat diesen Verbraucher nicht aktiv zugeordnet.'); } + $this->WriteAttributeString('Betriebsart', $daten['Betriebsart']); $this->regelzyklus(false); $angebot = $this->leseLeistungsangebot(); $sollleistung = $daten['Sollleistung_W']; + if ($sollleistung === null) { + if ((bool) $this->leseZustand('SollwertGueltig') + && !in_array((int) $this->leseZustand('Sollleistung'), $angebot, true) + ) { + $this->setzeZustand('SollwertGueltig', false); + $this->regelzyklus(false); + } + $this->SetTimerInterval('RueckmeldungVerzoegert', 100); + return; + } if (!in_array($sollleistung, $angebot, true)) { throw new InvalidArgumentException('Sollleistung_W liegt nicht im aktuell gemeldeten Leistungsangebot.'); } @@ -363,45 +375,47 @@ class VerbraucherWarmwassererwaermer extends IPSModule implements VerbraucherSch (float) $this->ReadAttributeFloat('ZustandLegionellentemperatur') ); $hysterese = $this->ReadPropertyFloat('Hysterese'); - $maximaltemperaturErreicht = $this->ReadAttributeBoolean('MaximaltemperaturErreicht'); - if ($temperatur >= $maximum) { - $maximaltemperaturErreicht = true; - } elseif ($temperatur <= $maximum - $hysterese) { - $maximaltemperaturErreicht = false; - } - $this->WriteAttributeBoolean('MaximaltemperaturErreicht', $maximaltemperaturErreicht); - - $nachladen = (bool) $this->leseZustand('NachladenAktiv'); - if ($temperatur < $minimum) { - $nachladen = true; - } elseif ($temperatur >= $minimum + $hysterese) { - $nachladen = false; - } - $this->setzeZustand('NachladenAktiv', $nachladen); - - $zeitplanErfordertHeizen = false; $naechster = WarmwasserRegler::naechsterZeitplaneintrag( $this->ReadPropertyString('Zeitplan'), new DateTimeImmutable('now') ); - if ($naechster !== null && $temperatur < $naechster['Solltemperatur']) { - $zeitplanErfordertHeizen = !WarmwasserRegler::kannZielErreichen( + if ($naechster !== null + && $temperatur < $naechster['Solltemperatur'] + && !WarmwasserRegler::kannZielErreichen( (float) $this->ReadPropertyInteger('Boilervolumen'), $temperatur, $naechster['Solltemperatur'], max(0, $naechster['Zeitpunkt'] - $jetzt), $maximalleistung - ); + ) + ) { + $minimum = $naechster['Solltemperatur']; } - if ($nachladen || $zeitplanErfordertHeizen) { - return [[$maximalleistung], false, true, $maximalleistung]; + $stufeAktiv = (float) $this->leseZustand('Istleistung') > 0.0; + $nachladen = $temperatur < $minimum + || ($temperatur < $minimum + $hysterese && $stufeAktiv); + $this->setzeZustand('NachladenAktiv', $nachladen); + + $peakbetrieb = $this->ReadAttributeString('Betriebsart') + === Nachrichtenvertrag::BETRIEBSART_PEAK; + $angebot = WarmwasserRegler::leistungsangebot( + $peakbetrieb, + $temperatur, + $minimum, + $maximum, + $hysterese, + $stufeAktiv, + $alleLeistungen + ); + if ($angebot === [0]) { + return [$angebot, false, true, 0]; } - if ($maximaltemperaturErreicht) { - return [[0], false, true, 0]; + if ($angebot === [$maximalleistung]) { + return [$angebot, false, true, $maximalleistung]; } - return [$alleLeistungen, true, true, null]; + return [$angebot, true, true, null]; } private function aktualisiereLegionellenstatus(bool $temperaturGueltig, int $jetzt): void @@ -587,6 +601,7 @@ class VerbraucherWarmwassererwaermer extends IPSModule implements VerbraucherSch 'EmpfaengerID' => $managerID, 'Zeitpunkt' => time(), ], + 'Betriebsart' => $this->ReadAttributeString('Betriebsart'), 'PrioritaetPV' => $this->ReadPropertyInteger('PrioritaetPV'), 'PrioritaetPeak' => $this->ReadPropertyInteger('PrioritaetPeak'), 'Leistungswerte_W' => $this->leseLeistungsangebot(), diff --git a/docs/Schnittstelle.md b/docs/Schnittstelle.md index 8024357..ab61a67 100644 --- a/docs/Schnittstelle.md +++ b/docs/Schnittstelle.md @@ -1,6 +1,6 @@ # EMS-Schnittstelle -Vertragsversion: `3.0` +Vertragsversion: `4.0` Es gibt genau eine fachliche Empfangsmethode je Richtung: @@ -13,7 +13,7 @@ public function VerbraucherdatenEmpfangen(array $daten): void; | Feld | Typ | Bedeutung | | --- | --- | --- | -| `Version` | Text | Vertragsversion `3.0` | +| `Version` | Text | Vertragsversion `4.0` | | `AbsenderID` | Ganzzahl | Sendende Symcon-Instanz | | `EmpfaengerID` | Ganzzahl | Empfangende Symcon-Instanz | | `Zeitpunkt` | Ganzzahl | Unixzeit in UTC | @@ -23,118 +23,119 @@ public function VerbraucherdatenEmpfangen(array $daten): void; ```json { "Kopf": { - "Version": "3.0", + "Version": "4.0", "AbsenderID": 10001, "EmpfaengerID": 20001, "Zeitpunkt": 1788825600 }, + "Betriebsart": "PV", "Sollleistung_W": 1501 } ``` -`Sollleistung_W` ist immer eine Ganzzahl und muss im aktuell gemeldeten Leistungsangebot liegen. +`Betriebsart` ist `PV` oder `Peak`. `Sollleistung_W` ist eine Ganzzahl +aus dem fuer diese Betriebsart gemeldeten Leistungsangebot oder `null`. + +`null` kuendigt nur die Betriebsart an. Der Verbraucher uebernimmt sie, +berechnet sein Leistungsangebot neu und meldet es zurueck. Eine vorhandene +Sollleistung wird dabei nur verworfen, wenn sie im neuen Angebot nicht mehr +zulaessig ist. ## Verbraucher an Manager ```json { "Kopf": { - "Version": "3.0", + "Version": "4.0", "AbsenderID": 20001, "EmpfaengerID": 10001, "Zeitpunkt": 1788825602 }, + "Betriebsart": "Peak", "PrioritaetPV": 0, "PrioritaetPeak": 0, - "Leistungswerte_W": [ - -3000, - -2000, - {"Von_W": -1000, "Bis_W": -500}, - 0, - 100, - {"Von_W": 1000, "Bis_W": 2000}, - 3000 - ], - "AenderungMoeglich": true, + "Leistungswerte_W": [0], + "AenderungMoeglich": false, "Verfuegbar": true, - "Istleistung_W": 1498.5, - "Leistungsquelle": 2, + "Istleistung_W": 0, + "Leistungsquelle": 1, "Zustand": [ { "Kennung": "Sollleistung_W", "Art": "Sollwert", - "Wert": 1501, + "Wert": 0, "Einheit": "W" } ] } ``` -### Feste Regeln +## Betriebsart-Synchronisation -- Prioritaeten beginnen bei 0; eine kleinere Zahl bedeutet eine hoehere Prioritaet. +1. Der Manager bestimmt `PV` oder `Peak`. +2. Meldungen einer anderen Betriebsart werden nicht zur Verteilung verwendet. +3. Der Manager sendet diesen Verbrauchern eine Betriebsart-Ankuendigung mit + `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. + +Damit kann jeder Verbrauchertyp unterschiedliche Angebote fuer PV und Peak +melden, ohne dass der Manager seine interne Geraetelogik kennen muss. + +## Feste Regeln + +- Prioritaeten beginnen bei 0; eine kleinere Zahl bedeutet hoehere Prioritaet. - `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` einschliesslich. -- Die Leistungswerte sind aufsteigend, eindeutig und ueberschneiden sich nicht. -- `Zustand` enthaelt immer den gemeinsamen Eintrag `Sollleistung_W` und daneben nur benoetigte geraetespezifische Eintraege. -- Eine aktive Stoerung wird mit `Art=Stoerung` und `Wert=true` gemeldet; eine behobene mit `false`. -- Verbraucher werden ausschliesslich im Manager zugeordnet. Der Verbraucher besitzt keine Manager-ID-Property. +- Leistungsbereiche enthalten jeden ganzen Wattwert von `Von_W` bis `Bis_W`. +- Leistungswerte sind aufsteigend, eindeutig und ueberschneiden sich nicht. +- `Zustand` enthaelt immer `Sollleistung_W`. +- Verbraucher werden ausschliesslich im Manager zugeordnet. +- Der Verbraucher besitzt keine Manager-ID-Property. ## Technische Umsetzung in IP-Symcon -Der Transport erfolgt über `IPS_RequestAction` mit JSON. `MessageSink` erkennt -registrierte Änderungen; der empfangende Action-Handler prüft das Paket und -ruft danach genau eine fachliche Empfangsmethode auf. Empfang und Neuberechnung -werden intern entkoppelt, damit keine gegenseitige Endlosschleife entsteht. +Der Transport erfolgt ueber `IPS_RequestAction` mit JSON. `MessageSink` +erkennt registrierte Aenderungen. Empfang und Neuberechnung sind intern +entkoppelt, damit keine gegenseitige Endlosschleife entsteht. -Das PHP-Interface legt nur die Methode fest. Die folgenden gemeinsamen -Symcon-Datenpunkte werden durch `VerbraucherBasisTrait` registriert. +Das PHP-Interface legt nur die Empfangsmethode fest. Die gemeinsamen +Symcon-Datenpunkte registriert `VerbraucherBasisTrait`. ### Gemeinsame Properties aller Verbraucher | Ident | Typ | Standard | Beschreibung | | --- | --- | --- | --- | -| `PrioritaetPV` | Integer | `0` | Priorität ab 0; kleinere Zahl bedeutet höhere Priorität. | -| `PrioritaetPeak` | Integer | `0` | Wie PV-Priorität, ohne fachliche Obergrenze. | -| `Meldeintervall` | Integer | `10` | Vollständige Rückmeldung in Sekunden; muss grösser als 0 sein. | -| `VorgabeTimeout` | Integer | `120` | Sollwert wird nach dieser Zeit ohne Erneuerung ungültig. | -| `EinstellungenInVisu` | Boolean | `false` | Blendet lokale Einstellvariablen ein und macht sie geprüft bedienbar. | -| `LoggingEin` | Boolean | `false` | Aktiviert das laufende Diagnoseprotokoll. Module können zusätzliche Diagnosevariablen getrennt einblenden. | +| `PrioritaetPV` | Integer | `0` | Prioritaet in der Betriebsart PV | +| `PrioritaetPeak` | Integer | `0` | Prioritaet in der Betriebsart Peak | +| `Meldeintervall` | Integer | `10` | Vollstaendige Rueckmeldung in Sekunden | +| `VorgabeTimeout` | Integer | `120` | Ablaufzeit einer Sollleistung | +| `EinstellungenInVisu` | Boolean | `false` | Lokale Einstellungen in der Visualisierung | +| `LoggingEin` | Boolean | `false` | Laufendes Diagnoseprotokoll | ### Gemeinsame Variablen aller Verbraucher | Ident | Typ / Zugriff | Beschreibung | | --- | --- | --- | -| `Aktiv` | Boolean / bedienbar | Lokale EMS-Freigabe; Start `false`. | -| `Istleistung` | Float / Anzeige | Aktuelle Leistung in W; nur zusammen mit `Leistungsquelle` gültig. | -| `Leistungsquelle` | Integer / Anzeige | `0` nicht vorhanden, `1` berechnet, `2` gemessen. | -| `Sollleistung` | Integer / Anzeige | Tatsächlich angenommene Vorgabe in W. | -| `SollwertGueltig` | Boolean / Anzeige | Kennzeichnet eine aktuell gültige Vorgabe. | -| `Verfuegbar` | Boolean / Anzeige | Verbraucher grundsätzlich für EMS-Steuerung verfügbar. | -| `AenderungMoeglich` | Boolean / Anzeige | Neue Vorgabe darf momentan übernommen werden. | -| `Stoerung` | Boolean / Anzeige | Mindestens ein Zustandseintrag der Art `Stoerung` ist aktiv. | -| `Stoertext` | String / Anzeige | Zusammengefasste lesbare Störbeschreibung. | +| `Aktiv` | Boolean / bedienbar | Lokale EMS-Freigabe; Start `false` | +| `Istleistung` | Float / Anzeige | Aktuelle Leistung in W | +| `Leistungsquelle` | Integer / Anzeige | 0 nicht vorhanden, 1 berechnet, 2 gemessen | +| `Sollleistung` | Integer / Anzeige | Angenommene oder lokal erzwungene Vorgabe | +| `SollwertGueltig` | Boolean / Anzeige | Aktuelle, nicht abgelaufene Vorgabe | +| `Verfuegbar` | Boolean / Anzeige | Verbraucher grundsaetzlich verfuegbar | +| `AenderungMoeglich` | Boolean / Anzeige | Neue Vorgabe darf uebernommen werden | +| `Stoerung` | Boolean / Anzeige | Mindestens eine Stoerung aktiv | +| `Stoertext` | String / Anzeige | Zusammengefasste Stoerbeschreibung | -`Leistungswerte_W` und `Zustand` werden intern gehalten und direkt in die -Nachricht geschrieben. Dafür werden keine zusätzlichen Symcon-Variablen -angelegt. Der gemeinsame Zustandseintrag `Sollleistung_W` wird durch die Basis -ergänzt. Während einer Lastwechselsperre meldet ein Verbraucher -`AenderungMoeglich` als `false` und beschränkt `Leistungswerte_W` auf die aktuell -gehaltene Leistung. Separate Felder wie `Idle` oder `IdleCounter` sind nicht -Bestandteil des Vertrags. +`Leistungswerte_W`, `Betriebsart` und `Zustand` werden intern gehalten +und direkt in die Nachricht geschrieben. ## Zeitverhalten -- Rückmeldung nach dem Start, bei relevanten Änderungen und zusätzlich alle - `Meldeintervall` Sekunden. -- Laufende Vorgaben werden vom Manager standardmässig alle 60 Sekunden erneuert. -- Nach `VorgabeTimeout` Sekunden ist eine nicht erneuerte Vorgabe ungültig. -- Nach einem Neustart wird zuerst der Gerätezustand erfasst; eine alte Vorgabe - wird nicht ungeprüft wieder aufgenommen. - -## Obere Anschlüsse des Managers - -SDL/VGT, Prognose, Lizenzierung und Störüberwachung verändern den -Manager-Verbraucher-Vertrag nicht. Ihr aktueller Diskussionsstand ist in -[Obere-Anschluesse.md](Obere-Anschluesse.md) beschrieben. +- Rueckmeldung nach Start, relevanten Aenderungen und alle `Meldeintervall` + Sekunden. +- Laufende Vorgaben werden vom Manager standardmaessig erneuert. +- Nach `VorgabeTimeout` ist eine nicht erneuerte Vorgabe ungueltig. +- Nach einem Neustart wird keine alte Vorgabe ungeprueft aufgenommen. diff --git a/docs/adr/0004-betriebsartabhaengige-leistungsangebote.md b/docs/adr/0004-betriebsartabhaengige-leistungsangebote.md new file mode 100644 index 0000000..49a14f4 --- /dev/null +++ b/docs/adr/0004-betriebsartabhaengige-leistungsangebote.md @@ -0,0 +1,76 @@ +# ADR 0004: Betriebsartabhaengige Leistungsangebote + +## Kontext + +Der Nachrichtenvertrag 3.0 uebermittelte vom Manager nur +`Sollleistung_W`. Verbraucher meldeten ein einziges Leistungsangebot und +kannten die aktuelle Betriebsart nicht. Damit konnten Module keine +unterschiedlichen PowerSteps fuer PV- und Peakbetrieb bereitstellen. + +Eine einfache Ergaenzung des Sollwertpakets reicht nicht aus: Beim Wechsel der +Betriebsart besitzt der Manager zunaechst noch das Angebot der vorherigen +Betriebsart. Eine sofortige Verteilung koennte deshalb einen Sollwert erzeugen, +den der Verbraucher im neuen Modus ablehnen muss. + +## Entscheidung + +Der Vertrag wird inkompatibel auf Version `4.0` angehoben. + +- Managerdaten und Verbraucherdaten enthalten verpflichtend `Betriebsart` + mit dem Wert `PV` oder `Peak`. +- `Sollleistung_W` in Managerdaten darf `null` sein. +- `null` ist eine reine Betriebsart-Ankuendigung und kein Schaltbefehl. +- Der Verbraucher uebernimmt die Betriebsart, berechnet sein Leistungsangebot + neu und meldet dieses mit derselben Betriebsart zurueck. +- Der Manager verwendet nur Angebote seiner aktuellen Betriebsart. +- Solange mindestens ein aktiver Verbraucher nicht synchronisiert ist, erfolgt + keine Verteilung. +- Nach einem Betriebsartwechsel verwirft der Manager seinen Sollwertcache. + +Die konkrete Differenz zwischen PV- und Peakangebot bleibt Verantwortung des +Verbrauchermoduls. Die derzeit implementierten Verbraucher uebernehmen die +zustandsabhaengigen Enelix-1-Angebote gezielt: + +- Der einstufige Verbraucher bietet in Peak normalerweise `[0]`. Bei faelliger + Tagesmindestlaufzeit ist konfigurierbar, ob `[0, Nennleistung]` angeboten + oder nur die Nennleistung erzwungen wird. +- Die Ladestation bietet in Peak mit Solarladen `[0]`, ohne Solarladen dagegen + `[0, ...Ladestufen]` an. +- Der Warmwassererwaermer bietet unter seiner wirksamen Mindesttemperatur auch + in Peak `[0, ...Leistungsstufen]` an. +- Der Pufferspeicher bietet im Peakbetrieb unabhaengig vom Zustand `[0]` an. +- Technische Schaltsperren und ausstehende Rueckmeldungen duerfen das Angebot + weiterhin auf die aktuell gehaltene Leistung begrenzen. + +## Alternativen + +### Zwei Angebote gleichzeitig melden + +Separate Felder fuer PV- und Peak-PowerSteps wuerden den Umschalt-Handshake +vermeiden. Sie verdoppeln jedoch alle Angebotsdaten und zwingen jedes Modul, +beide Zustaende jederzeit parallel zu berechnen. + +### Betriebsart ohne Synchronisation senden + +Der Manager koennte Betriebsart und Sollleistung in einem Paket senden. Das +erste Kommando nach einem Wechsel waere dann aus dem alten Angebot berechnet +und koennte ungueltig sein. + +### Betriebsart aus einer Manager-Variable lesen + +Eine direkte Objektkopplung wuerde die definierte Schnittstelle umgehen, +mehrere Manager erschweren und Verbraucher unnoetig an die Managerinstanz +binden. + +## Folgen + +- Alle Manager und Verbraucher einer Installation muessen gemeinsam auf + Vertrag 4.0 aktualisiert werden. +- Version 3.0 und 4.0 koennen nicht innerhalb derselben Managerzuordnung + gemischt werden. +- Ein Betriebsartwechsel benoetigt mindestens einen zusaetzlichen + Nachrichtenumlauf. +- Tests pruefen Vertragsvalidierung, Synchronisation und modulspezifische + Angebote fuer PV und Peak. +- ADR 0001 bleibt als historische Entscheidung erhalten, wird fuer den + aktuellen Vertrag aber durch diese Entscheidung abgeloest. diff --git a/docs/module/Ladestation-Stand-Alone/README.md b/docs/module/Ladestation-Stand-Alone/README.md index 5dd55a4..cbfd032 100644 --- a/docs/module/Ladestation-Stand-Alone/README.md +++ b/docs/module/Ladestation-Stand-Alone/README.md @@ -1,46 +1,63 @@ # Ladestation Stand-Alone -> Status: Diskussionsentwurf. Eigenständiges Verbrauchermodul mit direkter -> Geräteanbindung; keine Beziehung zur Ladestation Gateway. +> Status: Implementiert. Eigenstaendiges Verbrauchermodul mit direkter +> Geraeteanbindung; keine Beziehung zur Ladestation Gateway. -## Zusätzliche Variablen +## Unterstuetzte Geraete + +| Geraetetyp | Status | Steuerung | +| --- | --- | --- | +| go-e Charger, alte API | `GET /mqtt?payload=` | `alw` und `amp` | +| go-e Charger Gemini / Gemini flex | `GET /api/status` | `frc` und `amp` | +| smart-me Pico | Pico-Charging-API | Load-Management-Current-API mit Basic Auth | + +Die Routen, Leistungsfaktoren und Erkennung wurden gezielt aus Enelix 1 +uebernommen. Zugangsdaten werden weder als Variable noch im Diagnoseprotokoll +ausgegeben. + +## Verhalten + +- `FahrzeugVerbunden` entspricht bei go-e `car != 1` und bei Pico `State != 1`. +- Die Phasenerkennung verwendet wie Enelix 1 die gemessene Ladeleistung: + ueber 7500 W sind dreiphasig, sonst einphasig. +- Ein bereits verbundenes Fahrzeug gilt bei einem ermittelten Maximalstrom + unter 6 A als geladen. +- PV mit Solarladen bietet `0 W` und alle Ladestufen von 6 A bis zum + konfigurierten Maximum an. +- PV ohne Solarladen bietet ausschliesslich die Maximalleistung an. +- Peak mit Solarladen bietet nur `[0]` an und stoppt die Ladestation. +- Peak ohne Solarladen bietet `[0, ...Ladestufen]` an, damit der Manager die + Ladeleistung wie in Enelix 1 stufenweise reduzieren kann. +- Ohne gueltige Manager-Vorgabe wird bei Solarladen mit 0 A und ohne + Solarladen mit maximaler Leistung gefahren. + +## Variablen | Ident | Typ / Zugriff | Beschreibung | | --- | --- | --- | -| `FahrzeugVerbunden` | Boolean / Anzeige | Nur bei gültigem Gerätestatus aussagekräftig. | -| `Fahrzeugstatus` | Integer / Anzeige | `0` unbekannt, `1` nicht verbunden, `2` bereit, `3` lädt, `4` voll, `5` Fehler. | -| `Ladestrom` | Float / Anzeige | Aktueller Ladestrom in A. | +| `FahrzeugVerbunden` | Boolean / Anzeige | Geraet meldet ein verbundenes Fahrzeug. | +| `FahrzeugGeladen` | Boolean / Anzeige | Enelix-1-Erkennung unter 6 A. | +| `Fahrzeugstatus` | Integer / Anzeige | `0` unbekannt, `1` getrennt, `2` bereit, `3` laedt, `4` geladen. | +| `Ladestrom` | Float / Anzeige | Aus der gemessenen Leistung ermittelter Strom in A. | | `Phasenzahl` | Integer / Anzeige | `0` unbekannt, `1` einphasig, `3` dreiphasig. | -| `Ladefreigabe` | Boolean / lokal bedienbar | Lokale Ladeerlaubnis zusätzlich zu `Aktiv`. | -| `Solarladen` | Boolean / lokal bedienbar | Überschussorientiertes Laden ein/aus. | +| `Ladefreigabe` | Boolean / bedienbar | Nur bei `EinstellungenInVisu`; lokale Ladeerlaubnis. | +| `Solarladen` | Boolean / bedienbar | Nur bei `EinstellungenInVisu`; variable Ladestufen ein/aus. | -## Zusätzliche Properties +## Properties -| Ident | Typ | Standard / Beschreibung | -| --- | --- | --- | -| `Ladefreigabe` | Boolean | `false`. | -| `Solarladen` | Boolean | `true`. | -| `MaximalerLadestrom` | Float | `0*` A; Installations- und Gerätegrenze. | -| `Ladestromschritt` | Float | `1` A; muss vom Gerät unterstützt sein. | -| `MindestEinzeit` | Integer | `0` s. | -| `MindestAuszeit` | Integer | `0` s. | -| `Geraetetyp` | Auswahl | `Unkonfiguriert`, `GoEAlt`, `GoEGemini` oder `SmartMePico`. | -| `Geraeteadresse` | String | leer; nur für lokale Adapter. | -| `GeraeteID` | String | leer; nur falls der Adapter eine ID benötigt. | -| `Seriennummer` | String | leer; nur falls benötigt. | -| `Benutzername` | String | leer; nur falls Anmeldung benötigt. | -| `Passwort` | String | leer; vertraulich und nie als Variable/Diagnose anzeigen. | +Neben den gemeinsamen Verbraucher-Properties werden Geraetetyp, Adresse, +Pico-ID, Seriennummer, Zugangsdaten, maximaler Ladestrom, Abfrageintervall +sowie die Startwerte fuer Ladefreigabe und Solarladen konfiguriert. -Minimaler Ladestrom, frühere Umschaltabstände, Benutzervariable und externe -Sollwert-Variable entfallen. Ladefreigabe und Solarladen erscheinen nur bei -aktivem `EinstellungenInVisu` als bedienbare Variablen. +Die gemeinsamen Verbraucher- und Diagnosevariablen entsprechen der +EMS-Schnittstelle Version 4.0. `LoggingEin` steuert ausschliesslich das +laufende Debug-Protokoll. -## Zustand +## Tests -`Fahrzeugstatus`, `FahrzeugVerbunden`, `Ladefreigabe`, `Solarladen` und -`Ladestrom_A` sowie `Ladefehler` und gerätespezifische Fehler. - -## Offene Punkte - -- Direkte Adapter und ihre noch gültigen API-Routen bei der Implementierung prüfen. -- Lokalen Mindestladebedarf bei deaktiviertem Solarladen genau festlegen. +Die Adaptertests verwenden einen injizierten Fake-HTTP-Transport. Dadurch +werden fuer alle drei Geraetevarianten Statusantworten, URL, HTTP-Methode, +Authentisierung und Steueraufrufe geprueft, ohne ein reales Geraet anzusprechen. +Der Symcon-Funktionstest nutzt den internen, nicht im Konfigurationsformular +sichtbaren `Testmodus` und prueft zusaetzlich Fahrzeug-/Phasenerkennung, +die vier PV-/Peak-Angebote sowie die Umschaltung ueber den `Solarladen`-Button. diff --git a/docs/module/Pufferspeicher/README.md b/docs/module/Pufferspeicher/README.md index 3e90bb3..70ba0c2 100644 --- a/docs/module/Pufferspeicher/README.md +++ b/docs/module/Pufferspeicher/README.md @@ -1,54 +1,144 @@ -# Pufferspeicher +# Verbraucher Pufferspeicher -> Status: Diskussionsentwurf. +> Status: implementiert fuer IP-Symcon 8.0+ und den Enelix-2-Vertrag `4.0`. -Regelt elektrische Heizstufen nach Pufferbedarf. Die Heizkurve verwendet einen -Fusspunkt bei 20 °C Aussentemperatur und eine Steigung: +Das Modul bindet elektrische Heizstufen eines Pufferspeichers an den +Enelix-Manager an. Die Freigabe wird aus Puffer- und Aussentemperatur, +Heizkurve, Hysterese, lokaler Aktivierung und aktueller Betriebsart berechnet. -`Solltemperatur = FusspunktVorlauftemperatur + HeizkurvenSteigung × (20 - Aussentemperatur)` +Es ist eine gezielte Adaption des Enelix-1-Moduls `Puffer_Speicher`. +Leistungsstufen, Temperaturquellen, optionale PT1-Glaettung und Heizkurve +wurden uebernommen und ueberarbeitet. Zyklische Altlogik, alte +Kommunikationsvariablen, `Idle` und `PowerSteps` als Symcon-Variable wurden +verworfen. -Anschliessend wird auf Mindest- und Maximaltemperatur begrenzt. +| Technisches Merkmal | Wert | +| --- | --- | +| Modulname | `VerbraucherPufferspeicher` | +| Alias | `Pufferspeicher` | +| Modul-ID | `{C92D5EEF-9632-47A5-9659-4B02BF40FBE9}` | +| Enelix-Vertrag | `4.0` | -## Zusätzliche Variablen +## Regelverhalten -| Ident | Typ / Zugriff | Beschreibung | +Die Solltemperatur wird berechnet als: + +`FusspunktVorlauftemperatur + HeizkurvenSteigung * (20 - Aussentemperatur)` + +Anschliessend wird sie auf `HeizkurveMinimaltemperatur` und +`HeizkurveMaximaltemperatur` begrenzt. + +Die Einschaltschwelle wird je nach `MindesttemperaturModus` bestimmt: + +- `Aus`: Solltemperatur minus Hysterese, +- `Statisch`: konfigurierte absolute Mindesttemperatur, +- `Differenz`: Solltemperatur minus konfigurierte Differenz. + +Es gilt: + +- im PV-Betrieb unter der Einschaltschwelle: `[0, ...Leistungsstufen]`, +- im PV-Betrieb an oder oberhalb der Einschaltschwelle: `[0]`, +- im Peakbetrieb unabhaengig von der Temperatur: `[0]`, +- bei `Aktiv=false`: immer `[0]`, +- bei ungueltigen Temperaturen: `[0]` und nicht verfuegbar. + +Der Pufferspeicher ist im Peakbetrieb damit pauschal gesperrt. Beim Wechsel in +Peak wird eine aktive Stufe sicher ausgeschaltet. + +## Properties + +Die Properties erscheinen im Formular in dieser Reihenfolge. + +### Manager und Zeitverhalten + +| Ident | Standard | Beschreibung | +| --- | ---: | --- | +| `PrioritaetPV` | `0` | Prioritaet im PV-Betrieb | +| `PrioritaetPeak` | `0` | Prioritaet im Peakbetrieb | +| `Meldeintervall` | `10 s` | Vollstaendige Rueckmeldung | +| `VorgabeTimeout` | `120 s` | Gueltigkeit einer Vorgabe | +| `LastwechselSperrzeit` | `5 s` | Mindestzeit zwischen Lastwechseln | + +### Pufferspeicher + +| Ident | Standard | Beschreibung | +| --- | ---: | --- | +| `LeistungsStufen` | `[]` | Stufe, Leistung und Boolean-Schaltkontakt | +| `PufferfuehlerVariableID` | `0` | Integer- oder Floatvariable | +| `AussentemperaturVariableID` | `0` | Integer- oder Floatvariable | + +### Heizkurve + +| Ident | Standard | Beschreibung | +| --- | ---: | --- | +| `FusspunktVorlauftemperatur` | `35.0 C` | Vorlauf bei 20 C aussen | +| `HeizkurvenSteigung` | `1.0` | Anhebung je Kelvin fallender Aussentemperatur | +| `HeizkurveMinimaltemperatur` | `20.0 C` | Untere Sollwertbegrenzung | +| `HeizkurveMaximaltemperatur` | `80.0 C` | Obere Sollwertbegrenzung | +| `Hysterese` | `5.0 K` | Abstand zur Einschaltschwelle | +| `MindesttemperaturModus` | `Aus` | Aus, statisch oder Differenz | +| `Mindesttemperatur` | `20.0 C` | Wert im statischen Modus | +| `MindesttemperaturDifferenz` | `5.0 K` | Wert im Differenzmodus | +| `WaermepumpenSolltemperaturVariableID` | `0` | Optionale Quelle fuer die WP-Uebernahme | + +Der Button **Wert von Waermepumpe uebernehmen** berechnet den Fusspunkt so, +dass die lokale Heizkurve beim aktuellen Aussenwert durch den aktuellen +WP-Sollwert verlaeuft. Es entsteht keine dauerhafte Laufzeitkopplung. + +### Erweiterte Einstellungen + +| Ident | Standard | Beschreibung | +| --- | ---: | --- | +| `TemperaturMaxAlter` | `120 s` | Maximal zulaessiges Alter beider Messwerte | +| `PuffertemperaturGlaetten` | `false` | Aktiviert PT1-Glaettung | +| `ZeitKonstante` | `120 s` | PT1-Zeitkonstante | +| `EinstellungenInVisu` | `false` | Gemeinsame Verbraucheroption | +| `DiagnosevariablenAnzeigen` | `false` | Diagnosevariablen einblenden | +| `LoggingEin` | `false` | Debug-Protokoll aktivieren | + +## Variablen + +Immer vorhanden: + +| Ident | Typ | Beschreibung | | --- | --- | --- | -| `Puffertemperatur` | Float / Anzeige | Aktuelle Temperatur in °C. | -| `Aussentemperatur` | Float / Anzeige | Heizkurveneingang in °C. | -| `TemperaturenGueltig` | Boolean / Anzeige | Beide Quellen sind gültig. | -| `Solltemperatur` | Float / Anzeige | Berechneter und begrenzter Sollwert in °C. | -| `Heizbedarf` | Boolean / Anzeige | Bedarf unter Berücksichtigung der Hysterese. | -| `AktiveStufe` | Integer / Anzeige | Stufenkennung; `0` bedeutet aus. | -| `FusspunktVorlauftemperatur` | Float / lokal bedienbar | Wirksamer Parameter in °C. | -| `HeizkurvenSteigung` | Float / lokal bedienbar | Anhebung pro K fallender Aussentemperatur. | -| `Mindesttemperatur` | Float / lokal bedienbar | Untere Begrenzung in °C. | -| `Maximaltemperatur` | Float / lokal bedienbar | Obere Begrenzung in °C. | -| `Hysterese` | Float / lokal bedienbar | Ein unter Soll minus Hysterese, aus bei Soll. | +| `Aktiv` | Boolean / bedienbar | Lokale Ein-/Aus-Freigabe | +| `Puffertemperatur` | Float | Aktueller, gegebenenfalls geglaetteter Wert | +| `Aussentemperatur` | Float | Heizkurveneingang | +| `Solltemperatur` | Float | Berechneter und begrenzter Sollwert | +| `Heizbedarf` | Boolean | Temperatur liegt unter der Einschaltschwelle | -## Zusätzliche Properties +Optional sichtbar sind die gemeinsamen Verbraucher-Diagnosevariablen sowie +`AktiveStufe`, `BezogeneEnergie` und `TemperaturenGueltig`. -| Ident | Typ | Standard / Beschreibung | -| --- | --- | --- | -| `FusspunktVorlauftemperatur` | Float | `0*` °C; Vorlauf bei 20 °C aussen. | -| `HeizkurvenSteigung` | Float | `0*`; mindestens 0. | -| `Mindesttemperatur` | Float | `0*` °C. | -| `Maximaltemperatur` | Float | `0*` °C. | -| `Hysterese` | Float | `1` K; grösser 0. | -| `Leistungsstufen` | String/JSON | `[]`; Stufenkennung, Leistung in W und Ausgangsbelegung. | -| `PuffertemperaturVariableID` | Integer | `0`; Temperaturquelle. | -| `AussentemperaturVariableID` | Integer | `0`; Aussentemperaturquelle. | -| `Mindestlaufzeit` | Integer | `0` s. | -| `Mindeststillstand` | Integer | `0` s. | +## Managerkommunikation -Vier bisherige Heizkurventemperaturen, beide Heizkurvenquellen, Übernahmeaktion, -Zeitplan und Zeitzone entfallen. +Das Modul implementiert `VerbraucherSchnittstelle` und Vertrag `4.0`. +Es uebernimmt zuerst die vom Manager angekuendigte Betriebsart, berechnet das +passende Leistungsangebot und meldet es mit derselben Betriebsart zurueck. +Erst danach wird eine Sollleistung angenommen. -## Zustand +Die Rueckmeldung enthaelt zusaetzlich: -`Puffertemperatur_C` und `Aussentemperatur_C` als Istwerte, -`Solltemperatur_C` als Sollwert, `Heizbedarf` als Status und `Fuehlerfehler` als -Störung. +- `Puffertemperatur_C`, +- `Aussentemperatur_C`, +- `Solltemperatur_C`, +- `Heizbedarf`, +- `AktiveStufe`, +- `Fuehlerfehler` und `Schaltfehler`. -## Offene Punkte +## Sicherheit und Schaltung -- Optionale Einbindung der Wärmepumpe beim Pufferheizen ausarbeiten. +Die Kontakte werden Break-before-make geschaltet: zuerst alle aus, danach +hoechstens eine Stufe ein. Ein Schaltfehler fuehrt zu einem Ausschaltversuch +aller Kontakte und einer Stoerungsmeldung. Hardwareseitige Verriegelungen, +Temperaturbegrenzer und Schutzorgane bleiben erforderlich. + +## Tests + +- `PufferspeicherReglerTest` prueft Heizkurve, Hysterese, Ein/Aus, Peak und + alle Mindesttemperatur-Modi. +- `PufferspeicherModulstrukturTest` prueft Metadaten, Properties, Formular, + Vertrag und Schaltfolge. +- Der standardisierte Symcon-Test prueft die reale Modulinstanz und ist im + gemeinsamen Manifest sowie in der Manager-Matrix registriert. diff --git a/docs/module/README.md b/docs/module/README.md index b0a88fb..20f23bc 100644 --- a/docs/module/README.md +++ b/docs/module/README.md @@ -1,7 +1,8 @@ # EMS-Module und Modulentwürfe -> Manager, Warmwassererwaermer und Verbraucher 1-Stufig sind als installierbare -> IP-Symcon-Module umgesetzt. Die weiteren Ordner enthalten Besprechungsgrundlagen. +> Manager, Warmwassererwaermer, Verbraucher 1-Stufig und Ladestation +> Stand-Alone sind als installierbare IP-Symcon-Module umgesetzt. Die weiteren +> Ordner enthalten Besprechungsgrundlagen. Alle steuerbaren Verbraucher verwenden die gemeinsamen Datenpunkte aus der [EMS-Schnittstelle](../Schnittstelle.md). In den Modul-READMEs stehen deshalb @@ -15,7 +16,7 @@ nur zusätzliche Properties, Variablen, Zustände und offene Punkte. | [Pufferspeicher](Pufferspeicher/README.md) | Heizstufen nach Heizkurve | | [Verbraucher 1-Stufig](Verbraucher-1-Stufig/README.md) | Ein-/Aus-Verbraucher (implementiert) | | [Wärmepumpe](Waermepumpe/README.md) | Sperrkontakt oder SG Ready | -| [Ladestation Stand-Alone](Ladestation-Stand-Alone/README.md) | Direkte Geräteanbindung | +| [Ladestation Stand-Alone](Ladestation-Stand-Alone/README.md) | Direkte Geräteanbindung (implementiert) | | [Ladestation Gateway](Ladestation-Gateway/README.md) | Ladestation am Easee Gateway | | [Easee Gateway](Easee-Gateway/README.md) | Gemeinsame Easee-Kommunikation | diff --git a/docs/module/Verbraucher-1-Stufig/README.md b/docs/module/Verbraucher-1-Stufig/README.md index 73b63fd..05a825e 100644 --- a/docs/module/Verbraucher-1-Stufig/README.md +++ b/docs/module/Verbraucher-1-Stufig/README.md @@ -1,12 +1,15 @@ # Verbraucher 1-Stufig -> Status: implementiert fuer IP-Symcon 8 und den Enelix-2-Vertrag `3.0`. +> Status: implementiert fuer IP-Symcon 8 und den Enelix-2-Vertrag `4.0`. -Das Modul schaltet einen elektrischen Ein-/Aus-Verbraucher. Im freien Zustand -meldet es dem Manager das Leistungsangebot `[0, Nennleistung]`. Waerend einer -Mindest-Ein- oder Mindest-Aus-Zeit, waehrend einer ausstehenden Rueckmeldung -und bei lokal erzwungener Tagesmindestlaufzeit wird nur der aktuelle -bestaetigte Leistungswert angeboten. +Das Modul schaltet einen elektrischen Ein-/Aus-Verbraucher. Im frei +schaltbaren Zustand meldet es bei PV `[0, Nennleistung]` und bei Peak `[0]`. +Ist die Tagesmindestlaufzeit faellig, wird bei PV die Nennleistung erzwungen. +Bei Peak ist konfigurierbar, ob `[0, Nennleistung]` angeboten oder die +Nennleistung ohne Sperrmoeglichkeit erzwungen wird. Waerend einer +Mindest-Ein- oder Mindest-Aus-Zeit sowie bei einer ausstehenden Rueckmeldung +wird unabhaengig von der Betriebsart nur der aktuelle bestaetigte +Leistungswert angeboten. ## Architekturentscheidung @@ -27,7 +30,7 @@ Regelberechnung. ## Properties Zu den sechs gemeinsamen Verbraucher-Properties aus -[`Schnittstelle.md`](../../Schnittstelle.md) kommen acht +[`Schnittstelle.md`](../../Schnittstelle.md) kommen neun Modul-Properties hinzu. | Ident | Typ | Standard | Beschreibung | @@ -37,6 +40,7 @@ Modul-Properties hinzu. | `SchaltkontaktInvertiert` | Boolean | `false` | Kehrt die Ein-/Aus-Semantik des Aktors um. | | `RueckmeldungVariableID` | Integer | `0` | Optionale Boolean-Rueckmeldung; `true` bedeutet physisch eingeschaltet. | | `Mindestlaufzeit` | Integer | `0` | Geforderte Laufzeit pro lokalem Kalendertag in Sekunden, maximal 86400. | +| `PeakSperreBeiMindestlaufzeitAnbieten` | Boolean | `true` | Bietet bei faelliger Tagesmindestlaufzeit im Peakbetrieb zusaetzlich `0` als Sperre an. Bei `false` wird die Nennleistung lokal erzwungen. | | `Mindesteinschaltdauer` | Integer | `5` | Mindestzeit in Sekunden, die ein bestaetigter Ein-Zustand gehalten wird. | | `Mindestausschaltdauer` | Integer | `5` | Mindestzeit in Sekunden, die ein bestaetigter Aus-Zustand gehalten wird. | | `DiagnosevariablenAnzeigen` | Boolean | `false` | Legt gemeinsame und modulspezifische Diagnosevariablen an. | @@ -126,15 +130,20 @@ beruecksichtigt. Nur der bestaetigte Ein-Zustand zaehlt. Ist die konfigurierte Tagesmindestlaufzeit noch nicht erreicht, wird der Verbraucher zum spaetestmoeglichen Zeitpunkt angefordert, an dem die -Restlaufzeit bis Mitternacht noch erfuellt werden kann. Eine noch aktive -Mindestausschaltdauer hat dabei Vorrang; eine unmoegliche Restlaufzeit kann -nicht rueckwirkend nachgeholt werden. +Restlaufzeit bis Mitternacht noch erfuellt werden kann. Im PV-Betrieb wird die +Nennleistung dann lokal erzwungen. Im Peakbetrieb meldet das Modul bei +aktivierter Property `PeakSperreBeiMindestlaufzeitAnbieten` wie Enelix 1 +`[0, Nennleistung]`, sodass der Manager zwischen Sperren und Nachholen der +Laufzeit waehlen kann. Ist die Property deaktiviert, wird auch im Peakbetrieb +nur die Nennleistung angeboten und lokal erzwungen. Eine noch aktive +Mindestausschaltdauer hat Vorrang; eine unmoegliche Restlaufzeit kann nicht +rueckwirkend nachgeholt werden. ## Managerkommunikation Das Modul implementiert `VerbraucherSchnittstelle::ManagerdatenEmpfangen()` und verwendet -ausschliesslich Vertrag `3.0`. Der Verbraucher besitzt keine +ausschliesslich Vertrag `4.0`. Der Verbraucher besitzt keine Manager-ID-Property. Er akzeptiert nur Manager, in deren manueller oder automatischer Verbraucherzuordnung seine Instanz aktiv eingetragen ist. diff --git a/docs/module/Wassererwaermer/README.md b/docs/module/Wassererwaermer/README.md index e354669..d1844b5 100644 --- a/docs/module/Wassererwaermer/README.md +++ b/docs/module/Wassererwaermer/README.md @@ -1,12 +1,12 @@ # Verbraucher Warmwassererwaermer -> Status: implementiert fuer IP-Symcon 8.0+ und den Enelix-2-Vertrag `3.0`. +> Status: implementiert fuer IP-Symcon 8.0+ und den Enelix-2-Vertrag `4.0`. Das Modul bindet einen elektrischen Warmwasserspeicher mit einer oder mehreren exklusiven Leistungsstufen an den Enelix-Manager an. Der Manager kann nur eine -der aktuell angebotenen Leistungen vorgeben. Lokale Schutz- und Komfortregeln -fuer Mindesttemperatur, Maximaltemperatur, Zeitplan und Legionellenfunktion -haben Vorrang vor einer Manager-Vorgabe. +der aktuell angebotenen Leistungen vorgeben. Mindesttemperatur, Hysterese, +Zeitplan und Legionellenfunktion bestimmen dabei zustands- und +betriebsartabhaengig das Leistungsangebot. Das Modul ist eine gezielte Adaption des Enelix-1-Moduls `Boiler_x_Stufig`. Zyklische Altlogik und alte Kommunikationsvariablen wurden @@ -20,7 +20,7 @@ ausschliesslich den Enelix-2-Nachrichtenvertrag. | Modul-ID | `{B7C54AF4-AD7D-4FE4-B75D-203693906251}` | | Modultyp | Geraeteinstanz (`3`) | | Funktionspraefix | `ENELIX` | -| Enelix-Vertrag | `3.0` | +| Enelix-Vertrag | `4.0` | ## Funktionsumfang @@ -65,22 +65,22 @@ Eine Neuberechnung wird insbesondere ausgeloest durch: vollstaendige Rueckmeldung sicher. Die Lastwechselsperre verwendet einen einmaligen Timer und loest nach ihrem Ablauf genau eine Neuberechnung aus. -Die Zielentscheidung folgt dieser Prioritaet: +Die Zielentscheidung folgt der Enelix-1-Zustandsmatrix: 1. Bei deaktivierter Instanz oder ungueltiger Temperatur ist der Verbraucher nicht verfuegbar und wird ausgeschaltet. Eine ungueltige Konfiguration wird bereits beim Uebernehmen mit Status `202` abgewiesen. -2. Unterschreitet die Temperatur die wirksame Mindesttemperatur oder reicht die - verbleibende Zeit bis zum naechsten Zeitplanziel rechnerisch nicht aus, wird - die hoechste Leistungsstufe lokal erzwungen. -3. Ab der wirksamen Maximaltemperatur wird ausgeschaltet. -4. Im Bereich unmittelbar unter der Maximaltemperatur wird eine bereits aktive - Stufe bis zum Erreichen der Abschaltgrenze gehalten. -5. In allen anderen Zustaenden darf der Manager zwischen `0 W` und allen - konfigurierten Leistungsstufen waehlen. +2. Unter der wirksamen Mindesttemperatur sowie in der unteren Hysterese bei + aktiver Stufe gilt: PV erzwingt die Maximalleistung, Peak bietet + `[0, ...Leistungsstufen]` an. +3. Ausserhalb dieses Mindesttemperaturbereichs bietet Peak nur `[0]` an. +4. Im PV-Betrieb werden unterhalb von `Maximaltemperatur - Hysterese` alle + Stufen angeboten. In der oberen Hysterese gilt dies nur, solange bereits + eine Stufe aktiv ist. +5. An oder oberhalb der wirksamen Maximaltemperatur wird ausgeschaltet. -Lokale Zwangsvorgaben werden mit `AenderungMoeglich=false` und einem -entsprechend eingeschraenkten `Leistungswerte_W` gemeldet. +Eine erzwungene PV-Maximalleistung wird mit `AenderungMoeglich=false` +gemeldet. Das Peak-Array bleibt dagegen durch den Manager waehlbar. ## Konfiguration @@ -129,10 +129,11 @@ Stufe konfiguriert werden. Der jeweils naechste Zeitplaneintrag wird fuer heute oder den folgenden Tag ermittelt. Reicht die verbleibende Zeit bei maximaler elektrischer Leistung -rechnerisch nicht mehr aus, wird sofort die hoechste Stufe angefordert. Die -Berechnung verwendet Wasser mit `4186 J/(kg K)` und beruecksichtigt keine -Speicher- oder Leitungsverluste. Fuer Datum und Uhrzeit gilt die in IP-Symcon -eingestellte lokale Zeitzone. +rechnerisch nicht mehr aus, wird das Zeitplanziel zur wirksamen +Mindesttemperatur. Im PV-Betrieb wird dann die hoechste Stufe erzwungen, im +Peakbetrieb werden alle Stufen angeboten. Die Berechnung verwendet Wasser mit +`4186 J/(kg K)` und beruecksichtigt keine Speicher- oder Leitungsverluste. Fuer +Datum und Uhrzeit gilt die in IP-Symcon eingestellte lokale Zeitzone. ### Erweiterte sonstige Einstellungen @@ -179,14 +180,14 @@ nur erlaubt, wenn `EinstellungenInVisu=true` gesetzt ist. ## Hysterese und lokales Nachladen -Sinkt die Temperatur unter die wirksame Mindesttemperatur, beginnt das lokale -Nachladen mit der hoechsten Leistungsstufe. Es endet, sobald mindestens -`Mindesttemperatur + Hysterese` erreicht ist. Oberhalb der wirksamen -Maximaltemperatur wird ausgeschaltet. +Sinkt die Temperatur unter die wirksame Mindesttemperatur, erzwingt PV die +hoechste Leistungsstufe; Peak bietet dagegen Aus und alle Stufen an. In der +unteren Hysterese bis `Mindesttemperatur + Hysterese` bleibt dieses Verhalten +nur erhalten, solange bereits eine Stufe aktiv ist. -Ist bereits eine Stufe aktiv und liegt die Temperatur bei mindestens -`Maximaltemperatur - Hysterese`, wird diese Stufe bis zur Abschaltgrenze -gehalten. Dadurch werden unnoetige Stufenwechsel kurz vor dem Ziel vermieden. +Im PV-Betrieb werden unter `Maximaltemperatur - Hysterese` alle Stufen +angeboten. Innerhalb der oberen Hysterese bleibt das Angebot nur bei einer +bereits aktiven Stufe erhalten. An der Maximaltemperatur wird ausgeschaltet. ## Legionellenfunktion @@ -196,8 +197,8 @@ Die Legionellenfunktion arbeitet zweistufig: wirksamen Maximaltemperatur. Der Manager kann die Aufheizung innerhalb des verbleibenden Zeitfensters ermoeglichen. 2. Ab `LegionellenMaximalintervallTage` wird die Legionellentemperatur auch zur - wirksamen Mindesttemperatur. Die hoechste Stufe wird dadurch lokal - erzwungen, bis das Ziel erreicht ist. + wirksamen Mindesttemperatur. PV erzwingt dadurch die Maximalleistung; Peak + bietet das vollstaendige Stufenarray bis zum Ziel an. Erreicht die gueltige Speichertemperatur die Legionellentemperatur, wird der Zeitpunkt als erfolgreicher Abschluss gespeichert und das Intervall beginnt @@ -268,7 +269,7 @@ Anzeigevariablen. Das Modul implementiert `VerbraucherSchnittstelle::ManagerdatenEmpfangen()` und verwendet den -Enelix-2-Vertrag `3.0`. Es besitzt keine Manager-ID-Property. Zugelassen sind +Enelix-2-Vertrag `4.0`. Es besitzt keine Manager-ID-Property. Zugelassen sind nur Manager, in deren manueller oder automatischer Verbraucherzuordnung die Instanz aktiv eingetragen ist. @@ -382,7 +383,7 @@ PT1, Lastwechselsperre, Energieberechnung, thermische Prognose, Zeitplan und Legionellengrenzen. `WarmwassererwaermerModulstrukturTest.php` prueft Metadaten, Formular, alle 19 -Properties, ereignisbasierte Lastwechselsperre, Vertrag `3.0`, bedarfsgesteuerte +Properties, ereignisbasierte Lastwechselsperre, Vertrag `4.0`, bedarfsgesteuerte Diagnosevariablen, Legionellentemperatur, Temperatursollwerte und Break-before-make-Schaltung. diff --git a/docs/testing/README.md b/docs/testing/README.md index 06ec1a9..1b3b9c2 100644 --- a/docs/testing/README.md +++ b/docs/testing/README.md @@ -42,7 +42,7 @@ werden, wenn sie vor dem Lauf nicht existierten. - `single`: genau die als Auswahl übergebenen Module - `affected`: die durch geänderte Pfade ermittelten Module -Verfügbare Module: `Manager`, `VerbraucherEinStufig`, `Warmwassererwaermer`. +Verfügbare Module: `LadestationStandAlone`, `Manager`, `VerbraucherEinStufig`, `Warmwassererwaermer`. Der Manager-Test enthält Manager ohne Verbraucher, jeden Verbrauchertyp einzeln und alle aktuell implementierten Verbrauchertypen gemeinsam. diff --git a/examples/Demoanlage/bootstrap.php b/examples/Demoanlage/bootstrap.php index fabfde5..fb560b1 100644 --- a/examples/Demoanlage/bootstrap.php +++ b/examples/Demoanlage/bootstrap.php @@ -6,6 +6,7 @@ const ENELIX_DEMO_ROOT_IDENT = 'ENELIX_DEMO_2'; const ENELIX_DEMO_MANAGER_MODULE = '{6F771B18-59D4-4C8A-B951-3B2FE9F6A2C4}'; const ENELIX_DEMO_BOILER_MODULE = '{B7C54AF4-AD7D-4FE4-B75D-203693906251}'; const ENELIX_DEMO_SINGLE_MODULE = '{15879A4E-D0C2-4495-83DE-46E1E462591E}'; +const ENELIX_DEMO_DUMMY_MODULE = '{485D0419-BE97-4548-AA9C-C083EB82E61E}'; const ENELIX_DEMO_TILE_MODULE = '{B5B875BB-9B76-45FD-4E67-2607E45B3AC4}'; const ENELIX_DEMO_ARCHIVE_MODULE = '{43192F0B-135B-4CE7-A0A7-1475603F3060}'; const ENELIX_DEMO_ENERGY_DISTRIBUTION_MODULE = '{BA90FDB3-C203-8F26-B4D6-F3DB99905724}'; @@ -37,12 +38,14 @@ function enelixDemoInstall(): array $boiler2Power = enelixDemoEnsureVariable($dashboard, 'Boiler2Leistung', 'Boiler 2 Leistung', 2, 'ENELIX.Demo.PowerPositive', 0.0, 70); $dryerPower = enelixDemoEnsureVariable($dashboard, 'EntfeuchterLeistung', 'Entfeuchter Leistung', 2, 'ENELIX.Demo.PowerPositive', 0.0, 80); - $powerChart = enelixDemoEnsureVariable($history, 'Leistungsdiagramm', 'Leistungsverlauf', 3, '~HTMLBox', '', 10); - $energyChart = enelixDemoEnsureVariable($history, 'Energiediagramm', 'Kumulierte Energien', 3, '~HTMLBox', '', 20); - $pvEnergy = enelixDemoEnsureVariable($history, 'PVEnergie', 'PV-Energie', 2, 'ENELIX.Demo.Energy', 0.0, 30); - $houseEnergy = enelixDemoEnsureVariable($history, 'HausEnergie', 'Hausverbrauch Energie', 2, 'ENELIX.Demo.Energy', 0.0, 40); - $gridImportEnergy = enelixDemoEnsureVariable($history, 'NetzbezugEnergie', 'Netzbezug Energie', 2, 'ENELIX.Demo.Energy', 0.0, 50); - $gridExportEnergy = enelixDemoEnsureVariable($history, 'EinspeisungEnergie', 'Einspeisung Energie', 2, 'ENELIX.Demo.Energy', 0.0, 60); + $powerChart = enelixDemoEnsureVariable($history, 'Leistungsdiagramm', 'Leistungsverlauf (Legacy)', 3, '~HTMLBox', '', 90); + $energyChart = enelixDemoEnsureVariable($history, 'Energiediagramm', 'Kumulierte Energien (Legacy)', 3, '~HTMLBox', '', 100); + IPS_SetHidden($powerChart, true); + IPS_SetHidden($energyChart, true); + $pvEnergy = enelixDemoEnsureVariable($history, 'PVEnergie', 'PV-Energie', 2, '~Electricity', 0.0, 30); + $houseEnergy = enelixDemoEnsureVariable($history, 'HausEnergie', 'Hausverbrauch Energie', 2, '~Electricity', 0.0, 40); + $gridImportEnergy = enelixDemoEnsureVariable($history, 'NetzbezugEnergie', 'Netzbezug Energie', 2, '~Electricity', 0.0, 50); + $gridExportEnergy = enelixDemoEnsureVariable($history, 'EinspeisungEnergie', 'Einspeisung Energie', 2, '~Electricity', 0.0, 60); $auto = enelixDemoEnsureVariable($simulation, 'Automatik', 'Tageslauf automatisch', 0, '~Switch', true, 10); $hour = enelixDemoEnsureVariable($simulation, 'Tageszeit', 'Simulierte Tageszeit', 2, 'ENELIX.Demo.Hour', 8.0, 20); @@ -89,6 +92,21 @@ function enelixDemoInstall(): array $chartUpdated = enelixDemoEnsureVariable($technical, 'LetztesDiagrammUpdate', 'Letztes Diagramm-Update', 1, '', 0, 170); IPS_SetHidden($chartUpdated, true); + $flowPv = enelixDemoEnsureVariable($technical, 'EnergieflussPVkW', 'PV-Leistung', 2, '~Power', 0.0, 300); + $flowGrid = enelixDemoEnsureVariable($technical, 'EnergieflussNetzkW', 'Netzleistung', 2, '~Power', 0.0, 310); + $flowBase = enelixDemoEnsureVariable($technical, 'EnergieflussGrundlastkW', 'Haus-Grundlast', 2, '~Power', 0.0, 320); + $flowBoiler1 = enelixDemoEnsureVariable($technical, 'EnergieflussBoiler1kW', 'Boiler 1', 2, '~Power', 0.0, 330); + $flowBoiler2 = enelixDemoEnsureVariable($technical, 'EnergieflussBoiler2kW', 'Boiler 2', 2, '~Power', 0.0, 340); + $flowDryer = enelixDemoEnsureVariable($technical, 'EnergieflussEntfeuchterkW', 'Entfeuchter', 2, '~Power', 0.0, 350); + $flowHouse = enelixDemoEnsureVariable($technical, 'DiagrammHausverbrauchkW', 'Hausverbrauch gesamt', 2, '~Power', 0.0, 360); + $flowLoads = enelixDemoEnsureVariable($technical, 'DiagrammVerbraucherkW', 'Geregelte Verbraucher', 2, '~Power', 0.0, 370); + foreach ([ + $flowPv, $flowGrid, $flowBase, $flowBoiler1, + $flowBoiler2, $flowDryer, $flowHouse, $flowLoads, + ] as $flowVariableId) { + IPS_SetHidden($flowVariableId, true); + } + foreach ([$boiler11, $boiler12, $boiler21, $boiler22, $dryContact] as $actorId) { IPS_SetVariableCustomAction($actorId, $actorScript); } @@ -148,19 +166,24 @@ function enelixDemoInstall(): array $energyDistribution = enelixDemoEnsureInstance( $dashboard, - 'Energieverteilung', - 'Energieverteilung', + 'EnergieverteilungNativ', + 'Energiefluss', ENELIX_DEMO_ENERGY_DISTRIBUTION_MODULE, 10 ); enelixDemoConfigureEnergyDistribution($energyDistribution, [ - enelixDemoEnergyNode($pvDisplay, 'PV-Anlage', 'solar'), - enelixDemoEnergyNode($grid, 'Stromnetz', 'grid'), - enelixDemoEnergyNode($base, 'Haus-Grundlast', 'consumer'), - enelixDemoEnergyNode($boiler1Power, 'Boiler 1', 'consumer', $temp1), - enelixDemoEnergyNode($boiler2Power, 'Boiler 2', 'consumer', $temp2), - enelixDemoEnergyNode($dryerPower, 'Entfeuchter', 'consumer'), + enelixDemoEnergyNode($flowPv, 'PV-Anlage', 'solar'), + enelixDemoEnergyNode($flowGrid, 'Stromnetz', 'grid'), + enelixDemoEnergyNode($flowBase, 'Haus-Grundlast', 'consumer'), + enelixDemoEnergyNode($flowBoiler1, 'Boiler 1', 'consumer', $temp1), + enelixDemoEnergyNode($flowBoiler2, 'Boiler 2', 'consumer', $temp2), + enelixDemoEnergyNode($flowDryer, 'Entfeuchter', 'consumer'), ]); + IPS_SetHidden($energyDistribution, false); + $oldEnergyDistribution = enelixDemoFind($dashboard, 'Energieverteilung'); + if ($oldEnergyDistribution > 0 && $oldEnergyDistribution !== $energyDistribution) { + IPS_SetHidden($oldEnergyDistribution, true); + } foreach ([$boiler1, $boiler2] as $boilerId) { IPS_RequestAction($boilerId, 'Mindesttemperatur', 42.0); @@ -194,16 +217,37 @@ function enelixDemoInstall(): array } IPS_RequestAction($manager, 'Aktiv', true); - enelixDemoLinkInstanceVariables($boiler1View, $boiler1, $temp1); - enelixDemoLinkInstanceVariables($boiler2View, $boiler2, $temp2); + $boiler1List = enelixDemoEnsureInstance( + $dashboard, + 'Boiler1Liste', + 'Boiler 1', + ENELIX_DEMO_DUMMY_MODULE, + 110 + ); + $boiler2List = enelixDemoEnsureInstance( + $dashboard, + 'Boiler2Liste', + 'Boiler 2', + ENELIX_DEMO_DUMMY_MODULE, + 120 + ); + enelixDemoConfigure($boiler1List, []); + enelixDemoConfigure($boiler2List, []); + enelixDemoLinkInstanceVariables($boiler1List, $boiler1, $temp1); + enelixDemoLinkInstanceVariables($boiler2List, $boiler2, $temp2); IPS_SetHidden($boiler1View, true); IPS_SetHidden($boiler2View, true); - IPS_SetHidden($boiler1, false); - IPS_SetHidden($boiler2, false); + IPS_SetHidden($boiler1, true); + IPS_SetHidden($boiler2, true); + + foreach (['Boiler1Link', 'Boiler2Link'] as $oldLinkIdent) { + $oldLink = enelixDemoFind($dashboard, $oldLinkIdent); + if ($oldLink > 0) { + IPS_SetHidden($oldLink, true); + } + } enelixDemoEnsureLink($dashboard, 'ManagerLink', 'Manager', $manager, 100); - enelixDemoEnsureLink($dashboard, 'Boiler1Link', 'Boiler 1', $boiler1, 110); - enelixDemoEnsureLink($dashboard, 'Boiler2Link', 'Boiler 2', $boiler2, 120); enelixDemoEnsureLink($dashboard, 'EntfeuchterLink', 'Entfeuchter', $dryer, 130); enelixDemoEnsureLink($simulation, 'Boiler1TemperaturLink', 'Boiler 1 Temperatur', $temp1, 70); enelixDemoEnsureLink($simulation, 'Boiler2TemperaturLink', 'Boiler 2 Temperatur', $temp2, 80); @@ -213,7 +257,21 @@ function enelixDemoInstall(): array $boiler1Power, $boiler2Power, $dryerPower, $pvEnergy, $houseEnergy, $gridImportEnergy, $gridExportEnergy, $temp1, $temp2, + $flowPv, $flowGrid, $flowBase, $flowBoiler1, $flowBoiler2, $flowDryer, + $flowHouse, $flowLoads, ]); + enelixDemoEnsureChart($history, 'LeistungsdiagrammNativ', 'Leistungsverlauf', [ + enelixDemoChartDataset($flowPv, 'PV', '#f4c542', '~Power'), + enelixDemoChartDataset($flowHouse, 'Hausverbrauch', '#2bb673', '~Power'), + enelixDemoChartDataset($flowLoads, 'Geregelte Verbraucher', '#2786ac', '~Power'), + enelixDemoChartDataset($flowGrid, 'Netzleistung', '#e95d5d', '~Power'), + ], 10); + enelixDemoEnsureChart($history, 'EnergiediagrammNativ', 'Kumulierte Energien', [ + enelixDemoChartDataset($pvEnergy, 'PV', '#f4c542', '~Electricity'), + enelixDemoChartDataset($houseEnergy, 'Hausverbrauch', '#2bb673', '~Electricity'), + enelixDemoChartDataset($gridImportEnergy, 'Netzbezug', '#e95d5d', '~Electricity'), + enelixDemoChartDataset($gridExportEnergy, 'Einspeisung', '#38a3db', '~Electricity'), + ], 20); enelixDemoConfigureVisualization($root, $energyDistribution); enelixDemoTick('Install'); @@ -297,6 +355,14 @@ function enelixDemoTick(string $source = 'Manual'): void SetValue(enelixDemoRequiredId($dashboard, 'Boiler1Leistung'), (float) $boiler1); SetValue(enelixDemoRequiredId($dashboard, 'Boiler2Leistung'), (float) $boiler2); SetValue(enelixDemoRequiredId($dashboard, 'EntfeuchterLeistung'), (float) $dryer); + SetValue(enelixDemoRequiredId($technical, 'EnergieflussPVkW'), round($pv / 1000.0, 3)); + SetValue(enelixDemoRequiredId($technical, 'EnergieflussNetzkW'), round($grid / 1000.0, 3)); + SetValue(enelixDemoRequiredId($technical, 'EnergieflussGrundlastkW'), round($base / 1000.0, 3)); + SetValue(enelixDemoRequiredId($technical, 'EnergieflussBoiler1kW'), round($boiler1 / 1000.0, 3)); + SetValue(enelixDemoRequiredId($technical, 'EnergieflussBoiler2kW'), round($boiler2 / 1000.0, 3)); + SetValue(enelixDemoRequiredId($technical, 'EnergieflussEntfeuchterkW'), round($dryer / 1000.0, 3)); + SetValue(enelixDemoRequiredId($technical, 'DiagrammHausverbrauchkW'), round($house / 1000.0, 3)); + SetValue(enelixDemoRequiredId($technical, 'DiagrammVerbraucherkW'), round($power / 1000.0, 3)); $history = enelixDemoRequiredId($root, 'Historie'); $energyIds = [ @@ -313,11 +379,6 @@ function enelixDemoTick(string $source = 'Manual'): void enelixDemoAddEnergy($energyIds['export'], max(0.0, -$grid), $hours); } - $chartUpdated = enelixDemoRequiredId($technical, 'LetztesDiagrammUpdate'); - if ($source !== 'Timer' || time() - (int) GetValue($chartUpdated) >= 10) { - enelixDemoUpdateCharts($history, [$pvId, $houseId, $gridId], $energyIds); - SetValue($chartUpdated, time()); - } } function enelixDemoRenderFlow(array $state): string @@ -378,6 +439,7 @@ function enelixDemoValidateRuntime(): void ENELIX_DEMO_MANAGER_MODULE, ENELIX_DEMO_BOILER_MODULE, ENELIX_DEMO_SINGLE_MODULE, + ENELIX_DEMO_DUMMY_MODULE, ENELIX_DEMO_ENERGY_DISTRIBUTION_MODULE, ] as $moduleId) { if (!in_array($moduleId, $available, true)) { @@ -451,6 +513,53 @@ function enelixDemoEnsureVariable(int $parent, string $ident, string $name, int return $id; } +function enelixDemoEnsureChart( + int $parent, + string $ident, + string $name, + array $datasets, + int $position +): int { + $id = enelixDemoFind($parent, $ident); + if ($id === 0) { + $id = IPS_CreateMedia(4); + IPS_SetParent($id, $parent); + IPS_SetIdent($id, $ident); + } + enelixDemoRequireType($id, 5); + if ((int) IPS_GetMedia($id)['MediaType'] !== 4) { + throw new RuntimeException('Medientyp-Konflikt bei ' . $ident); + } + IPS_SetName($id, $name); + IPS_SetPosition($id, $position); + IPS_SetHidden($id, false); + IPS_SetMediaFile($id, 'media/' . $id . '.chart', false); + IPS_SetMediaContent( + $id, + base64_encode(json_encode(['datasets' => $datasets], JSON_THROW_ON_ERROR)) + ); + return $id; +} + +function enelixDemoChartDataset( + int $variableId, + string $title, + string $strokeColor, + string $profile +): array { + return [ + 'variableID' => $variableId, + 'fillColor' => 'clear', + 'strokeColor' => $strokeColor, + 'timeOffset' => 0, + 'visible' => true, + 'title' => $title, + 'type' => 'line', + 'side' => 'left', + 'profile' => $profile, + ]; +} + function enelixDemoEnsureScript(int $parent, string $ident, string $name, string $content, int $position): int { $id = enelixDemoFind($parent, $ident); @@ -642,12 +751,12 @@ function enelixDemoEnergyNode( return [ 'VariableConfigType' => 0, 'VariableID' => $variableId, - 'SecondaryVariableID' => 0, + 'SecondaryVariableID' => 1, 'Name' => $name, 'Type' => $type, 'Grouped' => false, 'CalculationType' => 0, - 'InfoVariableID' => $infoVariableId, + 'InfoVariableID' => $infoVariableId > 0 ? $infoVariableId : 1, 'Invert' => false, ]; } diff --git a/libs/EinStufigRegler.php b/libs/EinStufigRegler.php index 31d7d5a..487aa7c 100644 --- a/libs/EinStufigRegler.php +++ b/libs/EinStufigRegler.php @@ -18,7 +18,9 @@ final class EinStufigRegler int $istleistung, bool $verfuegbar, bool $aenderungMoeglich, - bool $mindestlaufzeitErzwungen + bool $mindestlaufzeitErzwungen, + bool $peakbetrieb, + bool $peakSperreBeiMindestlaufzeitAnbieten = true ): array { if ($nennleistung <= 0) { throw new InvalidArgumentException('Die Nennleistung muss groesser als 0 sein.'); @@ -27,9 +29,17 @@ final class EinStufigRegler throw new InvalidArgumentException('Die Istleistung muss 0 oder der Nennleistung entsprechen.'); } - if (!$verfuegbar || !$aenderungMoeglich || $mindestlaufzeitErzwungen) { + if (!$verfuegbar || !$aenderungMoeglich) { return [$istleistung]; } + if ($mindestlaufzeitErzwungen) { + return $peakbetrieb && $peakSperreBeiMindestlaufzeitAnbieten + ? [0, $nennleistung] + : [$nennleistung]; + } + if ($peakbetrieb) { + return [0]; + } return [0, $nennleistung]; } diff --git a/libs/LadestationAdapter.php b/libs/LadestationAdapter.php new file mode 100644 index 0000000..27d23e3 --- /dev/null +++ b/libs/LadestationAdapter.php @@ -0,0 +1,208 @@ + $konfiguration + * @return array + */ + public static function statusAnfrage(int $geraetetyp, array $konfiguration): array + { + if ($geraetetyp === self::GO_E_ALT) { + return self::anfrage('GET', self::lokaleBasisUrl($konfiguration) . '/mqtt?payload='); + } + if ($geraetetyp === self::GO_E_GEMINI) { + return self::anfrage('GET', self::lokaleBasisUrl($konfiguration) . '/api/status'); + } + if ($geraetetyp === self::SMART_ME_PICO) { + $id = self::pflichtwert($konfiguration, 'GeraeteID'); + + return self::anfrage( + 'GET', + 'https://api.smart-me.com/pico/charging/' . rawurlencode($id), + $konfiguration + ); + } + + throw new InvalidArgumentException('Unbekannter Geraetetyp.'); + } + + /** + * @param array $konfiguration + * @return list> + */ + public static function steuerAnfragen( + int $geraetetyp, + array $konfiguration, + int $ladestrom + ): array { + if ($ladestrom < 0 || $ladestrom > 32 || ($ladestrom > 0 && $ladestrom < 6)) { + throw new InvalidArgumentException('Ladestrom muss 0 oder 6 bis 32 A betragen.'); + } + + if ($geraetetyp === self::GO_E_ALT) { + $basis = self::lokaleBasisUrl($konfiguration) . '/mqtt?payload='; + if ($ladestrom === 0) { + return [self::anfrage('GET', $basis . 'alw=0')]; + } + + return [ + self::anfrage('GET', $basis . 'alw=1'), + self::anfrage('GET', $basis . 'amp=' . $ladestrom), + ]; + } + + if ($geraetetyp === self::GO_E_GEMINI) { + $basis = self::lokaleBasisUrl($konfiguration) . '/api/set?'; + if ($ladestrom === 0) { + return [self::anfrage('GET', $basis . 'frc=1&sua=false')]; + } + + return [ + self::anfrage('GET', $basis . 'frc=2'), + self::anfrage('GET', $basis . 'amp=' . $ladestrom), + ]; + } + + if ($geraetetyp === self::SMART_ME_PICO) { + $seriennummer = self::pflichtwert($konfiguration, 'Seriennummer'); + + return [self::anfrage( + 'POST', + 'https://api.smart-me.com/pico/loadmanagementgroup/current/' + . rawurlencode($seriennummer) + . '?current=' . ($ladestrom * 1000), + $konfiguration + )]; + } + + throw new InvalidArgumentException('Unbekannter Geraetetyp.'); + } + + /** + * @return array{Rohstatus: int, Istleistung_W: float} + */ + public static function dekodiereStatus(int $geraetetyp, string $antwort): array + { + $daten = json_decode($antwort, true, 512, JSON_THROW_ON_ERROR); + if (!is_array($daten)) { + throw new RuntimeException('Die Geraeteantwort ist kein JSON-Objekt.'); + } + + if ($geraetetyp === self::GO_E_ALT || $geraetetyp === self::GO_E_GEMINI) { + if (!isset($daten['car'], $daten['nrg']) + || !is_numeric($daten['car']) + || !is_array($daten['nrg']) + || !isset($daten['nrg'][11]) + || !is_numeric($daten['nrg'][11]) + ) { + throw new RuntimeException('Die go-e-Antwort enthaelt nicht alle Statusfelder.'); + } + + $faktor = $geraetetyp === self::GO_E_ALT ? 10.0 : 1.0; + + return [ + 'Rohstatus' => (int) $daten['car'], + 'Istleistung_W' => (float) $daten['nrg'][11] * $faktor, + ]; + } + + if ($geraetetyp === self::SMART_ME_PICO) { + if (!isset($daten['State'], $daten['ActiveChargingPower']) + || !is_numeric($daten['State']) + || !is_numeric($daten['ActiveChargingPower']) + ) { + throw new RuntimeException('Die Pico-Antwort enthaelt nicht alle Statusfelder.'); + } + + return [ + 'Rohstatus' => (int) $daten['State'], + 'Istleistung_W' => round((float) $daten['ActiveChargingPower'] * 1000.0), + ]; + } + + throw new InvalidArgumentException('Unbekannter Geraetetyp.'); + } + + /** + * @param array $konfiguration + * @return array + */ + private static function anfrage( + string $methode, + string $url, + array $konfiguration = [] + ): array { + $anfrage = ['Methode' => $methode, 'URL' => $url]; + if (str_starts_with($url, 'https://api.smart-me.com/')) { + $anfrage['Benutzername'] = self::pflichtwert($konfiguration, 'Benutzername'); + $anfrage['Passwort'] = self::pflichtwert($konfiguration, 'Passwort'); + } + + return $anfrage; + } + + /** @param array $konfiguration */ + private static function lokaleBasisUrl(array $konfiguration): string + { + $adresse = trim(self::pflichtwert($konfiguration, 'Geraeteadresse')); + if (str_contains($adresse, '/') || str_contains($adresse, '?')) { + throw new InvalidArgumentException('Geraeteadresse muss ein Hostname oder eine IP-Adresse sein.'); + } + + return 'http://' . $adresse; + } + + /** @param array $konfiguration */ + private static function pflichtwert(array $konfiguration, string $name): string + { + $wert = trim($konfiguration[$name] ?? ''); + if ($wert === '') { + throw new InvalidArgumentException($name . ' darf nicht leer sein.'); + } + + return $wert; + } +} + +final class LadestationTransport +{ + /** @var callable(array): string */ + private $anfrageAusfuehren; + + /** @param callable(array): string $anfrageAusfuehren */ + public function __construct(callable $anfrageAusfuehren) + { + $this->anfrageAusfuehren = $anfrageAusfuehren; + } + + /** @param array $konfiguration */ + public function statusLesen(int $geraetetyp, array $konfiguration): string + { + return ($this->anfrageAusfuehren)( + LadestationAdapter::statusAnfrage($geraetetyp, $konfiguration) + ); + } + + /** @param array $konfiguration */ + public function ladestromSetzen( + int $geraetetyp, + array $konfiguration, + int $ladestrom + ): void { + foreach (LadestationAdapter::steuerAnfragen($geraetetyp, $konfiguration, $ladestrom) as $anfrage) { + ($this->anfrageAusfuehren)($anfrage); + } + } +} diff --git a/libs/LadestationRegler.php b/libs/LadestationRegler.php new file mode 100644 index 0000000..55831b6 --- /dev/null +++ b/libs/LadestationRegler.php @@ -0,0 +1,146 @@ + */ + private const LEISTUNGEN_EINPHASIG = [ + 1450, 1690, 1930, 2170, 2410, 2650, 2890, 3130, 3370, + 3610, 3850, 4090, 4330, 4570, 4810, 5050, 5290, 5530, + 5770, 6010, 6250, 6490, 6730, 6970, 7210, 7450, 7690, + ]; + + /** @var list */ + private const LEISTUNGEN_DREIPHASIG = [ + 4150, 4850, 5550, 6250, 6950, 7600, 8300, 9000, 9700, + 10300, 11000, 11750, 12450, 13150, 13850, 14550, 15250, + 15900, 16600, 17300, 18000, 18700, 19400, 20100, 20800, + 21500, 22000, + ]; + + /** + * Die Schwelle, die Voll-Erkennung und der Zuschlag von 2.5 A stammen + * aus der Enelix-1-Ladestation und bleiben bewusst kompatibel. + * + * @param array{Rohstatus: int, Istleistung_W: float} $rohstatus + * @return array + */ + public static function normalisiereStatus(array $rohstatus, bool $warVerbunden): array + { + $verbunden = $rohstatus['Rohstatus'] !== 1; + $leistung = max(0.0, $rohstatus['Istleistung_W']); + $phasen = self::PHASEN_UNBEKANNT; + $ladestrom = 0.0; + $geladen = false; + + if ($verbunden) { + $phasen = $leistung > 7500.0 ? self::PHASEN_DREI : self::PHASEN_EIN; + $nenner = $phasen === self::PHASEN_EIN ? 230.0 : 1.71 * 400.0; + $ladestrom = $leistung / $nenner; + $ermittelterMaximalstrom = 2.5 + $ladestrom; + $geladen = $warVerbunden && $ermittelterMaximalstrom < 6.0; + } + + if (!$verbunden) { + $fahrzeugstatus = 1; + } elseif ($geladen) { + $fahrzeugstatus = 4; + } elseif ($leistung > 0.0) { + $fahrzeugstatus = 3; + } else { + $fahrzeugstatus = 2; + } + + return [ + 'FahrzeugVerbunden' => $verbunden, + 'FahrzeugGeladen' => $geladen, + 'Fahrzeugstatus' => $fahrzeugstatus, + 'Phasenzahl' => $phasen, + 'Istleistung_W' => $leistung, + 'Ladestrom_A' => $ladestrom, + ]; + } + + /** @return list */ + public static function leistungsangebot( + bool $aktiv, + bool $ladefreigabe, + bool $verbunden, + bool $geladen, + int $phasenzahl, + int $maximalerLadestrom, + bool $solarladen, + bool $peakbetrieb + ): array { + if (!$aktiv || !$ladefreigabe || !$verbunden || $geladen) { + return [0]; + } + + $stufen = self::leistungsstufen($phasenzahl, $maximalerLadestrom); + if ($stufen === []) { + return [0]; + } + if ($solarladen) { + return $peakbetrieb ? [0] : array_merge([0], $stufen); + } + if ($peakbetrieb) { + return array_merge([0], $stufen); + } + + return [$stufen[count($stufen) - 1]]; + } + + public static function stromFuerLeistung(int $leistung, int $phasenzahl): int + { + if ($leistung <= 0) { + return 0; + } + + $stufen = self::stufenFuerPhasen($phasenzahl); + if ($leistung < $stufen[0]) { + return 0; + } + + $strom = 6; + foreach ($stufen as $index => $schwelle) { + if ($leistung < $schwelle) { + break; + } + $strom = 6 + $index; + } + + return min(32, $strom); + } + + /** @return list */ + private static function leistungsstufen(int $phasenzahl, int $maximalerLadestrom): array + { + if ($maximalerLadestrom < 6 || $maximalerLadestrom > 32) { + throw new InvalidArgumentException('MaximalerLadestrom muss zwischen 6 und 32 A liegen.'); + } + + return array_slice(self::stufenFuerPhasen($phasenzahl), 0, $maximalerLadestrom - 5); + } + + /** @return list */ + private static function stufenFuerPhasen(int $phasenzahl): array + { + if ($phasenzahl === self::PHASEN_EIN) { + return self::LEISTUNGEN_EINPHASIG; + } + if ($phasenzahl === self::PHASEN_DREI) { + return self::LEISTUNGEN_DREIPHASIG; + } + + throw new InvalidArgumentException('Phasenzahl muss 1 oder 3 sein.'); + } +} diff --git a/libs/ManagerRegler.php b/libs/ManagerRegler.php index b8fe7d2..bde24b4 100644 --- a/libs/ManagerRegler.php +++ b/libs/ManagerRegler.php @@ -16,8 +16,8 @@ use InvalidArgumentException; final class ManagerRegler { public const BETRIEBSART_INAKTIV = 'Inaktiv'; - public const BETRIEBSART_PV = 'PV'; - public const BETRIEBSART_PEAK = 'Peak'; + public const BETRIEBSART_PV = Nachrichtenvertrag::BETRIEBSART_PV; + public const BETRIEBSART_PEAK = Nachrichtenvertrag::BETRIEBSART_PEAK; /** * @param array $monatsgrenzen Monatsnummer 1 bis 12 als Schluessel diff --git a/libs/Nachrichtenvertrag.php b/libs/Nachrichtenvertrag.php index 3810a2b..cd87d2a 100644 --- a/libs/Nachrichtenvertrag.php +++ b/libs/Nachrichtenvertrag.php @@ -8,7 +8,10 @@ use InvalidArgumentException; final class Nachrichtenvertrag { - public const VERSION = '3.0'; + public const VERSION = '4.0'; + + public const BETRIEBSART_PV = 'PV'; + public const BETRIEBSART_PEAK = 'Peak'; public const LEISTUNGSQUELLE_NICHT_VORHANDEN = 0; public const LEISTUNGSQUELLE_BERECHNET = 1; @@ -19,11 +22,12 @@ final class Nachrichtenvertrag */ public static function pruefeManagerdaten(array $daten): void { - self::pruefeSchluessel($daten, ['Kopf', 'Sollleistung_W'], 'Managerdaten'); + self::pruefeSchluessel($daten, ['Kopf', 'Betriebsart', 'Sollleistung_W'], 'Managerdaten'); self::pruefeKopf($daten['Kopf']); + self::pruefeBetriebsart($daten['Betriebsart']); - if (!is_int($daten['Sollleistung_W'])) { - throw new InvalidArgumentException('Sollleistung_W muss eine Ganzzahl sein.'); + if ($daten['Sollleistung_W'] !== null && !is_int($daten['Sollleistung_W'])) { + throw new InvalidArgumentException('Sollleistung_W muss eine Ganzzahl oder null sein.'); } } @@ -36,6 +40,7 @@ final class Nachrichtenvertrag $daten, [ 'Kopf', + 'Betriebsart', 'PrioritaetPV', 'PrioritaetPeak', 'Leistungswerte_W', @@ -49,6 +54,7 @@ final class Nachrichtenvertrag ); self::pruefeKopf($daten['Kopf']); + self::pruefeBetriebsart($daten['Betriebsart']); self::pruefePrioritaet($daten['PrioritaetPV'], 'PrioritaetPV'); self::pruefePrioritaet($daten['PrioritaetPeak'], 'PrioritaetPeak'); self::pruefeLeistungswerte($daten['Leistungswerte_W']); @@ -93,6 +99,18 @@ final class Nachrichtenvertrag } } + /** @param mixed $betriebsart */ + private static function pruefeBetriebsart($betriebsart): void + { + if (!is_string($betriebsart) || !in_array( + $betriebsart, + [self::BETRIEBSART_PV, self::BETRIEBSART_PEAK], + true + )) { + throw new InvalidArgumentException('Betriebsart muss PV oder Peak sein.'); + } + } + /** * @param mixed $prioritaet */ diff --git a/libs/PufferspeicherRegler.php b/libs/PufferspeicherRegler.php new file mode 100644 index 0000000..eea7f70 --- /dev/null +++ b/libs/PufferspeicherRegler.php @@ -0,0 +1,214 @@ + + */ + public static function dekodiereLeistungsstufen(string $json): array + { + $werte = json_decode($json, true, 512, JSON_THROW_ON_ERROR); + if (!is_array($werte) || !self::istListe($werte)) { + throw new InvalidArgumentException('LeistungsStufen muss eine JSON-Liste sein.'); + } + + $ergebnis = []; + $leistungen = []; + $kontakte = []; + foreach ($werte as $index => $eintrag) { + if (!is_array($eintrag)) { + throw new InvalidArgumentException('Jede Leistungsstufe muss ein Objekt sein.'); + } + $stufe = $eintrag['Stufe'] ?? $index + 1; + $leistung = $eintrag['Leistung'] ?? null; + $kontakt = $eintrag['Schaltkontakt_Stufe'] ?? null; + if (!is_int($stufe) || $stufe <= 0) { + throw new InvalidArgumentException('Stufe muss eine positive Ganzzahl sein.'); + } + if (!is_int($leistung) || $leistung <= 0) { + throw new InvalidArgumentException('Leistung muss eine positive Ganzzahl sein.'); + } + if (!is_int($kontakt) || $kontakt <= 0) { + throw new InvalidArgumentException('Schaltkontakt_Stufe muss eine positive Variablen-ID sein.'); + } + if (isset($leistungen[$leistung])) { + throw new InvalidArgumentException('Leistungen muessen eindeutig sein.'); + } + if (isset($kontakte[$kontakt])) { + throw new InvalidArgumentException('Schaltkontakte duerfen nur einer Stufe zugeordnet sein.'); + } + + $leistungen[$leistung] = true; + $kontakte[$kontakt] = true; + $ergebnis[] = [ + 'Stufe' => $stufe, + 'Leistung_W' => $leistung, + 'KontaktID' => $kontakt, + ]; + } + + usort( + $ergebnis, + static fn (array $links, array $rechts): int => $links['Leistung_W'] <=> $rechts['Leistung_W'] + ); + + return $ergebnis; + } + + /** + * @param list $stufen + * @return list + */ + public static function leistungswerte(array $stufen): array + { + return array_merge([0], array_column($stufen, 'Leistung_W')); + } + + /** + * @param list $stufen + */ + public static function stufeFuerLeistung(array $stufen, int $leistung): int + { + if ($leistung === 0) { + return 0; + } + foreach ($stufen as $stufe) { + if ($stufe['Leistung_W'] === $leistung) { + return $stufe['Stufe']; + } + } + + throw new InvalidArgumentException('Die Sollleistung entspricht keiner konfigurierten Leistungsstufe.'); + } + + public static function solltemperatur( + float $aussentemperatur, + float $fusspunkt, + float $steigung, + float $minimum, + float $maximum + ): float { + if ($steigung < 0.0) { + throw new InvalidArgumentException('HeizkurvenSteigung darf nicht negativ sein.'); + } + if ($minimum > $maximum) { + throw new InvalidArgumentException('Heizkurvenminimum darf nicht groesser als das Maximum sein.'); + } + + $soll = $fusspunkt + $steigung * (20.0 - $aussentemperatur); + + return min($maximum, max($minimum, $soll)); + } + + public static function einschaltschwelle( + float $solltemperatur, + float $hysterese, + int $minimumModus, + float $statischeMindesttemperatur, + float $mindesttemperaturDifferenz + ): float { + if ($hysterese <= 0.0) { + throw new InvalidArgumentException('Hysterese muss groesser als 0 sein.'); + } + + if ($minimumModus === self::MINIMUM_AUS) { + return $solltemperatur - $hysterese; + } + if ($minimumModus === self::MINIMUM_STATISCH) { + return min($solltemperatur, $statischeMindesttemperatur); + } + if ($minimumModus === self::MINIMUM_DIFFERENZ) { + if ($mindesttemperaturDifferenz < 0.0) { + throw new InvalidArgumentException('MindesttemperaturDifferenz darf nicht negativ sein.'); + } + + return $solltemperatur - $mindesttemperaturDifferenz; + } + + throw new InvalidArgumentException('MindesttemperaturModus muss 0, 1 oder 2 sein.'); + } + + /** + * @param list $stufen + * @return list + */ + public static function leistungsangebot( + bool $aktiv, + bool $peakbetrieb, + bool $temperaturenGueltig, + float $puffertemperatur, + float $einschaltschwelle, + array $stufen + ): array { + if (!$aktiv || $peakbetrieb || !$temperaturenGueltig || $stufen === []) { + return [0]; + } + if ($puffertemperatur < $einschaltschwelle) { + return self::leistungswerte($stufen); + } + + return [0]; + } + + public static function fusspunktAusWaermepumpenwert( + float $waermepumpenSolltemperatur, + float $aussentemperatur, + float $steigung + ): float { + if ($steigung < 0.0) { + throw new InvalidArgumentException('HeizkurvenSteigung darf nicht negativ sein.'); + } + + return $waermepumpenSolltemperatur - $steigung * (20.0 - $aussentemperatur); + } + + public static function pt1(float $bisher, float $eingang, int $deltaSekunden, int $zeitkonstante): float + { + if ($deltaSekunden <= 0 || $zeitkonstante <= 0) { + throw new InvalidArgumentException('PT1-Zeiten muessen groesser als 0 sein.'); + } + $alpha = $deltaSekunden / ($zeitkonstante + $deltaSekunden); + + return $bisher + $alpha * ($eingang - $bisher); + } + + public static function restzeitBisLastwechsel( + int $letzterLastwechsel, + int $jetzt, + int $sperrzeitSekunden + ): int { + if ($sperrzeitSekunden < 0) { + throw new InvalidArgumentException('LastwechselSperrzeit darf nicht negativ sein.'); + } + if ($letzterLastwechsel <= 0) { + return 0; + } + + return max(0, $sperrzeitSekunden - max(0, $jetzt - $letzterLastwechsel)); + } + + public static function energieKWh(float $leistungW, int $dauerSekunden): float + { + if ($dauerSekunden < 0) { + throw new InvalidArgumentException('Dauer darf nicht negativ sein.'); + } + + return $leistungW * $dauerSekunden / 3600000.0; + } + + /** @param array $wert */ + private static function istListe(array $wert): bool + { + return $wert === [] || array_keys($wert) === range(0, count($wert) - 1); + } +} diff --git a/libs/WarmwasserRegler.php b/libs/WarmwasserRegler.php index 7d19f03..6d7b022 100644 --- a/libs/WarmwasserRegler.php +++ b/libs/WarmwasserRegler.php @@ -78,6 +78,40 @@ final class WarmwasserRegler return array_merge([0], array_column($stufen, 'Leistung_W')); } + /** + * @param list $leistungswerte + * @return list + */ + public static function leistungsangebot( + bool $peakbetrieb, + float $temperatur, + float $mindesttemperatur, + float $maximaltemperatur, + float $hysterese, + bool $stufeAktiv, + array $leistungswerte + ): array { + if ($hysterese < 0.0 || $leistungswerte === []) { + throw new InvalidArgumentException('Hysterese und Leistungswerte sind ungueltig.'); + } + + $mindestladung = $temperatur < $mindesttemperatur + || ($temperatur < $mindesttemperatur + $hysterese && $stufeAktiv); + if ($peakbetrieb) { + return $mindestladung ? $leistungswerte : [0]; + } + if ($mindestladung) { + return [max($leistungswerte)]; + } + if ($temperatur < $maximaltemperatur - $hysterese + || ($temperatur < $maximaltemperatur && $stufeAktiv) + ) { + return $leistungswerte; + } + + return [0]; + } + /** * @param list $stufen */ diff --git a/tests/DemoanlageBootstrapTest.php b/tests/DemoanlageBootstrapTest.php index 4a307cf..dd451d0 100644 --- a/tests/DemoanlageBootstrapTest.php +++ b/tests/DemoanlageBootstrapTest.php @@ -62,7 +62,7 @@ final class DemoanlageBootstrapTest extends TestCase self::assertSame([ 'VariableConfigType' => 0, 'VariableID' => 12345, - 'SecondaryVariableID' => 0, + 'SecondaryVariableID' => 1, 'Name' => 'PV-Anlage', 'Type' => 'solar', 'Grouped' => false, @@ -70,6 +70,69 @@ final class DemoanlageBootstrapTest extends TestCase 'InfoVariableID' => 54321, 'Invert' => false, ], enelixDemoEnergyNode(12345, 'PV-Anlage', 'solar', 54321)); + self::assertSame( + 1, + enelixDemoEnergyNode(12345, 'PV-Anlage', 'solar')['InfoVariableID'] + ); + } + + public function testNativeDiagrammDatenreiheHatSymconFormat(): void + { + self::assertSame([ + 'variableID' => 12345, + 'fillColor' => 'clear', + 'strokeColor' => '#f4c542', + 'timeOffset' => 0, + 'visible' => true, + 'title' => 'PV', + 'type' => 'line', + 'side' => 'left', + 'profile' => '~Power', + ], enelixDemoChartDataset(12345, 'PV', '#f4c542', '~Power')); + } + + public function testDashboardVerwendetNativeEnergieverteilungUndNeueBoilerlisten(): void + { + $source = file_get_contents(dirname(__DIR__) . '/examples/Demoanlage/bootstrap.php'); + + self::assertIsString($source); + self::assertStringContainsString("IPS_SetHidden(\$legacyFlow, true);", $source); + self::assertStringContainsString("'EnergieverteilungNativ'", $source); + self::assertStringContainsString( + 'enelixDemoConfigureVisualization($root, $energyDistribution);', + $source + ); + self::assertStringContainsString('ENELIX_DEMO_DUMMY_MODULE', $source); + self::assertStringContainsString("'Boiler1Liste'", $source); + self::assertStringContainsString("'Boiler2Liste'", $source); + self::assertStringContainsString("'EnergieflussPVkW'", $source); + self::assertStringContainsString("'~Power'", $source); + self::assertStringContainsString("'~Electricity'", $source); + self::assertStringContainsString( + "enelixDemoEnergyNode(\$flowPv, 'PV-Anlage', 'solar')", + $source + ); + self::assertStringContainsString( + "enelixDemoEnergyNode(\$flowBase, 'Haus-Grundlast', 'consumer')", + $source + ); + self::assertStringContainsString( + "enelixDemoEnergyNode(\$flowDryer, 'Entfeuchter', 'consumer')", + $source + ); + self::assertStringContainsString( + "enelixDemoEnsureChart(\$history, 'LeistungsdiagrammNativ'", + $source + ); + self::assertStringContainsString('IPS_SetHidden($powerChart, true);', $source); + self::assertStringContainsString( + "enelixDemoLinkInstanceVariables(\$boiler1List, \$boiler1, \$temp1);", + $source + ); + self::assertStringContainsString( + "foreach (['Boiler1Link', 'Boiler2Link'] as \$oldLinkIdent)", + $source + ); } public function testRollbackIstExplizitBestaetigungspflichtig(): void diff --git a/tests/DokumentationsstrukturTest.php b/tests/DokumentationsstrukturTest.php index 6b25c68..17b7129 100644 --- a/tests/DokumentationsstrukturTest.php +++ b/tests/DokumentationsstrukturTest.php @@ -13,9 +13,7 @@ final class DokumentationsstrukturTest extends TestCase { return [ ['Batterie'], - ['Pufferspeicher'], ['Waermepumpe'], - ['Ladestation-Stand-Alone'], ['Ladestation-Gateway'], ['Easee-Gateway'], ]; @@ -33,6 +31,23 @@ final class DokumentationsstrukturTest extends TestCase self::assertStringContainsString('Properties', $inhalt); } + public function testPufferspeicherDokumentiertDenImplementiertenStand(): void + { + $inhalt = file_get_contents(__DIR__ . '/../docs/module/Pufferspeicher/README.md'); + self::assertNotFalse($inhalt); + self::assertStringContainsString('Status: implementiert', $inhalt); + self::assertStringContainsString('Managerkommunikation', $inhalt); + } + + public function testLadestationStandAloneDokumentiertDenImplementiertenStand(): void + { + $inhalt = file_get_contents(__DIR__ . '/../docs/module/Ladestation-Stand-Alone/README.md'); + self::assertNotFalse($inhalt); + self::assertStringContainsString('Status: Implementiert', $inhalt); + self::assertStringContainsString('Properties', $inhalt); + self::assertStringContainsString('Fake-HTTP-Transport', $inhalt); + } + public function testSchnittstellenDokumentiertSind(): void { self::assertFileExists(__DIR__ . '/../docs/Schnittstelle.md'); diff --git a/tests/LadestationAdapterTest.php b/tests/LadestationAdapterTest.php new file mode 100644 index 0000000..002b150 --- /dev/null +++ b/tests/LadestationAdapterTest.php @@ -0,0 +1,171 @@ + $konfiguration + * @param array{Rohstatus: int, Istleistung_W: float} $erwartet + */ + public function testStatusaufrufUndDekodierungFuerAlleGeraete( + int $geraetetyp, + array $konfiguration, + string $antwort, + string $erwarteteUrl, + array $erwartet + ): void { + $aufrufe = []; + $transport = new LadestationTransport(static function (array $anfrage) use ( + &$aufrufe, + $antwort + ): string { + $aufrufe[] = $anfrage; + return $antwort; + }); + + $rohstatus = LadestationAdapter::dekodiereStatus( + $geraetetyp, + $transport->statusLesen($geraetetyp, $konfiguration) + ); + + self::assertSame($erwarteteUrl, $aufrufe[0]['URL']); + self::assertSame('GET', $aufrufe[0]['Methode']); + self::assertSame($erwartet, $rohstatus); + } + + /** @return iterable> */ + public function statusFaelle(): iterable + { + yield 'go-e alt' => [ + LadestationAdapter::GO_E_ALT, + $this->lokaleKonfiguration(), + $this->goEAntwort(2, 1100), + 'http://192.0.2.10/mqtt?payload=', + ['Rohstatus' => 2, 'Istleistung_W' => 11000.0], + ]; + yield 'go-e Gemini' => [ + LadestationAdapter::GO_E_GEMINI, + $this->lokaleKonfiguration(), + $this->goEAntwort(2, 2300), + 'http://192.0.2.10/api/status', + ['Rohstatus' => 2, 'Istleistung_W' => 2300.0], + ]; + yield 'Smart-me Pico' => [ + LadestationAdapter::SMART_ME_PICO, + $this->picoKonfiguration(), + json_encode([ + 'State' => 2, + 'ActiveChargingPower' => 11.0, + 'MaxAllowedChargingCurrent' => 16, + ], JSON_THROW_ON_ERROR), + 'https://api.smart-me.com/pico/charging/pico-id', + ['Rohstatus' => 2, 'Istleistung_W' => 11000.0], + ]; + } + + /** + * @dataProvider steuerFaelle + * @param array $konfiguration + * @param list $erwarteteAufrufe + */ + public function testSteueraufrufeWerdenUeberFakeTransportGeprueft( + int $geraetetyp, + array $konfiguration, + int $strom, + array $erwarteteAufrufe + ): void { + $aufrufe = []; + $transport = new LadestationTransport(static function (array $anfrage) use (&$aufrufe): string { + unset($anfrage['Benutzername'], $anfrage['Passwort']); + $aufrufe[] = $anfrage; + return '{}'; + }); + + $transport->ladestromSetzen($geraetetyp, $konfiguration, $strom); + + self::assertSame($erwarteteAufrufe, $aufrufe); + } + + /** @return iterable> */ + public function steuerFaelle(): iterable + { + yield 'go-e alt laden' => [ + LadestationAdapter::GO_E_ALT, + $this->lokaleKonfiguration(), + 16, + [ + ['Methode' => 'GET', 'URL' => 'http://192.0.2.10/mqtt?payload=alw=1'], + ['Methode' => 'GET', 'URL' => 'http://192.0.2.10/mqtt?payload=amp=16'], + ], + ]; + yield 'go-e Gemini stoppen' => [ + LadestationAdapter::GO_E_GEMINI, + $this->lokaleKonfiguration(), + 0, + [[ + 'Methode' => 'GET', + 'URL' => 'http://192.0.2.10/api/set?frc=1&sua=false', + ]], + ]; + yield 'Smart-me Pico laden' => [ + LadestationAdapter::SMART_ME_PICO, + $this->picoKonfiguration(), + 16, + [[ + 'Methode' => 'POST', + 'URL' => 'https://api.smart-me.com/pico/loadmanagementgroup/current/serial-1?current=16000', + ]], + ]; + } + + public function testPicoAnfragenVerwendenBasicAuth(): void + { + $anfrage = LadestationAdapter::statusAnfrage( + LadestationAdapter::SMART_ME_PICO, + $this->picoKonfiguration() + ); + + self::assertSame('api-user', $anfrage['Benutzername']); + self::assertSame(hash('sha256', __FILE__), $anfrage['Passwort']); + } + + /** @return array */ + private function lokaleKonfiguration(): array + { + return [ + 'Geraeteadresse' => '192.0.2.10', + 'GeraeteID' => '', + 'Seriennummer' => '', + 'Benutzername' => '', + 'Passwort' => '', + ]; + } + + /** @return array */ + private function picoKonfiguration(): array + { + return [ + 'Geraeteadresse' => '', + 'GeraeteID' => 'pico-id', + 'Seriennummer' => 'serial-1', + 'Benutzername' => 'api-user', + 'Passwort' => hash('sha256', __FILE__), + ]; + } + + private function goEAntwort(int $status, float $leistung): string + { + $nrg = array_fill(0, 12, 0); + $nrg[11] = $leistung; + + return json_encode(['car' => $status, 'nrg' => $nrg], JSON_THROW_ON_ERROR); + } +} diff --git a/tests/LadestationReglerTest.php b/tests/LadestationReglerTest.php new file mode 100644 index 0000000..b3e09fd --- /dev/null +++ b/tests/LadestationReglerTest.php @@ -0,0 +1,67 @@ + 2, + 'Istleistung_W' => 11000.0, + ], false); + + self::assertTrue($status['FahrzeugVerbunden']); + self::assertFalse($status['FahrzeugGeladen']); + self::assertSame(3, $status['Fahrzeugstatus']); + self::assertSame(LadestationRegler::PHASEN_DREI, $status['Phasenzahl']); + } + + public function testEinphasigesFahrzeugWirdUnterhalb7500WErkannt(): void + { + $status = LadestationRegler::normalisiereStatus([ + 'Rohstatus' => 2, + 'Istleistung_W' => 2300.0, + ], false); + + self::assertSame(LadestationRegler::PHASEN_EIN, $status['Phasenzahl']); + self::assertEqualsWithDelta(10.0, $status['Ladestrom_A'], 0.01); + } + + public function testGeladenWirdNachVerbindungUnterSechsAmpereErkannt(): void + { + $status = LadestationRegler::normalisiereStatus([ + 'Rohstatus' => 2, + 'Istleistung_W' => 0.0, + ], true); + + self::assertTrue($status['FahrzeugGeladen']); + self::assertSame(4, $status['Fahrzeugstatus']); + } + + public function testBetriebsartUndSolarladenBestimmenDasLeistungsangebot(): void + { + $solarPV = LadestationRegler::leistungsangebot(true, true, true, false, 1, 16, true, false); + $normalPV = LadestationRegler::leistungsangebot(true, true, true, false, 1, 16, false, false); + $solarPeak = LadestationRegler::leistungsangebot(true, true, true, false, 1, 16, true, true); + $normalPeak = LadestationRegler::leistungsangebot(true, true, true, false, 1, 16, false, true); + + self::assertSame(0, $solarPV[0]); + self::assertCount(12, $solarPV); + self::assertSame([3850], $normalPV); + self::assertSame([0], $solarPeak); + self::assertSame($solarPV, $normalPeak); + } + + public function testPeakKannJedesFahrzeugAufNullSetzen(): void + { + self::assertSame(0, LadestationRegler::stromFuerLeistung(0, 3)); + self::assertSame(6, LadestationRegler::stromFuerLeistung(4150, 3)); + self::assertSame(16, LadestationRegler::stromFuerLeistung(11000, 3)); + } +} diff --git a/tests/LadestationStandAloneModulstrukturTest.php b/tests/LadestationStandAloneModulstrukturTest.php new file mode 100644 index 0000000..ae279f5 --- /dev/null +++ b/tests/LadestationStandAloneModulstrukturTest.php @@ -0,0 +1,73 @@ + 1, 'Zeitpunkt' => 1788825600, ], + 'Betriebsart' => Nachrichtenvertrag::BETRIEBSART_PV, 'PrioritaetPV' => $prioritaet, 'PrioritaetPeak' => $prioritaet, 'Leistungswerte_W' => $leistungswerte, diff --git a/tests/NachrichtenvertragTest.php b/tests/NachrichtenvertragTest.php index 4be655e..17899b1 100644 --- a/tests/NachrichtenvertragTest.php +++ b/tests/NachrichtenvertragTest.php @@ -14,12 +14,28 @@ final class NachrichtenvertragTest extends TestCase { Nachrichtenvertrag::pruefeManagerdaten([ 'Kopf' => $this->kopf(10001, 20001), + 'Betriebsart' => Nachrichtenvertrag::BETRIEBSART_PV, 'Sollleistung_W' => 1501, ]); + Nachrichtenvertrag::pruefeManagerdaten([ + 'Kopf' => $this->kopf(10001, 20001), + 'Betriebsart' => Nachrichtenvertrag::BETRIEBSART_PEAK, + 'Sollleistung_W' => null, + ]); + self::assertTrue(true); } + public function testUngueltigeBetriebsartWirdAbgelehnt(): void + { + $daten = $this->verbraucherdaten(); + $daten['Betriebsart'] = 'Unbekannt'; + + $this->expectException(InvalidArgumentException::class); + Nachrichtenvertrag::pruefeVerbraucherdaten($daten); + } + public function testGueltigeVerbraucherdatenMitEinzelwertenUndBereichenWerdenAkzeptiert(): void { Nachrichtenvertrag::pruefeVerbraucherdaten($this->verbraucherdaten()); @@ -92,6 +108,7 @@ final class NachrichtenvertragTest extends TestCase { return [ 'Kopf' => $this->kopf(20001, 10001), + 'Betriebsart' => Nachrichtenvertrag::BETRIEBSART_PEAK, 'PrioritaetPV' => 0, 'PrioritaetPeak' => 12, 'Leistungswerte_W' => [ diff --git a/tests/PufferspeicherModulstrukturTest.php b/tests/PufferspeicherModulstrukturTest.php new file mode 100644 index 0000000..a3f99ee --- /dev/null +++ b/tests/PufferspeicherModulstrukturTest.php @@ -0,0 +1,111 @@ +", $modul); + self::assertStringNotContainsString("RegisterVariableString('PowerSteps'", $modul); + self::assertStringNotContainsString("RegisterPropertyInteger('ManagerID'", $modul); + } + + public function testEinAusLoggingVariablenUndWaermepumpenaktionSindEnthalten(): void + { + $modul = (string) file_get_contents(self::MODULPFAD . 'module.php'); + $formular = (string) file_get_contents(self::MODULPFAD . 'form.json'); + + self::assertStringContainsString("case 'Aktiv':", $modul); + self::assertStringContainsString("ReadPropertyBoolean('LoggingEin')", $modul); + foreach ([ + 'Puffertemperatur', + 'Aussentemperatur', + 'Solltemperatur', + 'Heizbedarf', + ] as $ident) { + self::assertStringContainsString("RegisterVariable", $modul); + self::assertStringContainsString("'" . $ident . "'", $modul); + } + self::assertStringContainsString('HeizkurveVonWaermepumpeUebernehmen', $modul); + self::assertStringContainsString('Wert von Waermepumpe uebernehmen', $formular); + } + + public function testSchaltfolgeIstBreakBeforeMake(): void + { + $modul = (string) file_get_contents(self::MODULPFAD . 'module.php'); + $aus = strpos($modul, "RequestAction(\$stufe['KontaktID'], false)"); + $ein = strpos($modul, "RequestAction(\$stufe['KontaktID'], true)"); + + self::assertIsInt($aus); + self::assertIsInt($ein); + self::assertLessThan($ein, $aus); + } + + public function testManagerKenntDieModulIDGenauEinmal(): void + { + $manager = (string) file_get_contents(__DIR__ . '/../Manager/module.php'); + + self::assertSame(1, substr_count( + $manager, + '{C92D5EEF-9632-47A5-9659-4B02BF40FBE9}' + )); + } +} diff --git a/tests/PufferspeicherReglerTest.php b/tests/PufferspeicherReglerTest.php new file mode 100644 index 0000000..82d193c --- /dev/null +++ b/tests/PufferspeicherReglerTest.php @@ -0,0 +1,154 @@ + */ + private function stufen(): array + { + return [ + ['Stufe' => 1, 'Leistung_W' => 1000, 'KontaktID' => 101], + ['Stufe' => 2, 'Leistung_W' => 2000, 'KontaktID' => 102], + ]; + } + + public function testHeizkurveWirdBerechnetUndBegrenzt(): void + { + self::assertSame( + 50.0, + PufferspeicherRegler::solltemperatur(5.0, 35.0, 1.0, 20.0, 80.0) + ); + self::assertSame( + 80.0, + PufferspeicherRegler::solltemperatur(-100.0, 35.0, 1.0, 20.0, 80.0) + ); + self::assertSame( + 20.0, + PufferspeicherRegler::solltemperatur(100.0, 35.0, 1.0, 20.0, 80.0) + ); + } + + public function testUnterErrechneterEinschaltschwelleWerdenAlleStufenAngeboten(): void + { + self::assertSame( + [0, 1000, 2000], + PufferspeicherRegler::leistungsangebot( + true, + false, + true, + 44.9, + 45.0, + $this->stufen() + ) + ); + } + + public function testInHystereseUndDarueberWirdNurNullAngeboten(): void + { + self::assertSame( + [0], + PufferspeicherRegler::leistungsangebot( + true, + false, + true, + 45.0, + 45.0, + $this->stufen() + ) + ); + self::assertSame( + [0], + PufferspeicherRegler::leistungsangebot( + true, + false, + true, + 51.0, + 45.0, + $this->stufen() + ) + ); + } + + public function testPeakbetriebBietetUnabhaengigVonDerTemperaturNurNull(): void + { + foreach ([-20.0, 44.9, 45.0, 100.0] as $temperatur) { + self::assertSame( + [0], + PufferspeicherRegler::leistungsangebot( + true, + true, + true, + $temperatur, + 45.0, + $this->stufen() + ) + ); + } + } + + public function testAusgeschaltetWirdImmerNurNullAngeboten(): void + { + self::assertSame( + [0], + PufferspeicherRegler::leistungsangebot( + false, + false, + true, + -20.0, + 45.0, + $this->stufen() + ) + ); + } + + public function testMindesttemperaturIstOptionalStatischOderDifferenzbasiert(): void + { + self::assertSame( + 45.0, + PufferspeicherRegler::einschaltschwelle( + 50.0, + 5.0, + PufferspeicherRegler::MINIMUM_AUS, + 30.0, + 10.0 + ) + ); + self::assertSame( + 42.0, + PufferspeicherRegler::einschaltschwelle( + 50.0, + 5.0, + PufferspeicherRegler::MINIMUM_STATISCH, + 42.0, + 10.0 + ) + ); + self::assertSame( + 43.0, + PufferspeicherRegler::einschaltschwelle( + 50.0, + 5.0, + PufferspeicherRegler::MINIMUM_DIFFERENZ, + 30.0, + 7.0 + ) + ); + } + + public function testWaermepumpenwertKalibriertFusspunktAmAktuellenAussenwert(): void + { + $fusspunkt = PufferspeicherRegler::fusspunktAusWaermepumpenwert(50.0, 5.0, 1.0); + + self::assertSame(35.0, $fusspunkt); + self::assertSame( + 50.0, + PufferspeicherRegler::solltemperatur(5.0, $fusspunkt, 1.0, 0.0, 100.0) + ); + } +} diff --git a/tests/Symcon/manifest.php b/tests/Symcon/manifest.php index 1e9a16d..b366241 100644 --- a/tests/Symcon/manifest.php +++ b/tests/Symcon/manifest.php @@ -3,6 +3,17 @@ declare(strict_types=1); return [ + 'LadestationStandAlone' => [ + 'file' => __DIR__ . '/modules/LadestationStandAlone.php', + 'paths' => [ + 'LadestationStandAlone/*', + 'libs/LadestationAdapter.php', + 'libs/LadestationRegler.php', + 'libs/VerbraucherBasisTrait.php', + 'libs/VerbraucherSchnittstelle.php', + 'libs/Nachrichtenvertrag.php', + ], + ], 'Manager' => [ 'file' => __DIR__ . '/modules/Manager.php', 'paths' => [ @@ -14,6 +25,16 @@ return [ 'libs/VerbraucherSchnittstelle.php', ], ], + 'Pufferspeicher' => [ + 'file' => __DIR__ . '/modules/Pufferspeicher.php', + 'paths' => [ + 'Pufferspeicher/*', + 'libs/PufferspeicherRegler.php', + 'libs/VerbraucherBasisTrait.php', + 'libs/VerbraucherSchnittstelle.php', + 'libs/Nachrichtenvertrag.php', + ], + ], 'VerbraucherEinStufig' => [ 'file' => __DIR__ . '/modules/VerbraucherEinStufig.php', 'paths' => [ diff --git a/tests/Symcon/modules/LadestationStandAlone.php b/tests/Symcon/modules/LadestationStandAlone.php new file mode 100644 index 0000000..08c0b63 --- /dev/null +++ b/tests/Symcon/modules/LadestationStandAlone.php @@ -0,0 +1,177 @@ + $status, 'nrg' => $nrg], JSON_THROW_ON_ERROR); + }; + + $definitionen = [ + 'go-e alt' => [ + 'Geraetetyp' => 1, + 'Geraeteadresse' => '192.0.2.10', + 'Testantwort' => $goEAntwort(2, 1100), + 'Phasenzahl' => 3, + 'BefehlUrlTeil' => 'amp=16', + ], + 'go-e Gemini' => [ + 'Geraetetyp' => 2, + 'Geraeteadresse' => '192.0.2.11', + 'Testantwort' => $goEAntwort(2, 2300), + 'Phasenzahl' => 1, + 'BefehlUrlTeil' => 'amp=16', + ], + 'smart-me Pico' => [ + 'Geraetetyp' => 3, + 'GeraeteID' => 'pico-id', + 'Seriennummer' => 'serial-1', + 'Benutzername' => 'api-user', + 'Passwort' => $testPasswort, + 'Testantwort' => json_encode([ + 'State' => 2, + 'ActiveChargingPower' => 11.0, + 'MaxAllowedChargingCurrent' => 16, + ], JSON_THROW_ON_ERROR), + 'Phasenzahl' => 3, + 'BefehlUrlTeil' => 'current=16000', + ], + ]; + + $stationen = []; + foreach ($definitionen as $name => $definition) { + $properties = array_merge($definition, [ + 'MaximalerLadestrom' => 16, + 'Ladefreigabe' => true, + 'Solarladen' => true, + 'EinstellungenInVisu' => true, + 'DiagnosevariablenAnzeigen' => true, + 'Testmodus' => true, + ]); + unset($properties['Phasenzahl'], $properties['BefehlUrlTeil']); + $stationen[$name] = $test->createInstance( + '{0D94913C-0F31-4C29-A685-6EB4AE58E55D}', + 'Ladestation ' . $name, + $properties + ); + } + + $managerId = $test->createInstance( + '{6F771B18-59D4-4C8A-B951-3B2FE9F6A2C4}', + 'Hilfsmanager Ladestation', + [ + 'AutomatischeSuche' => false, + 'VerbraucherZuordnung' => json_encode(array_map( + static fn (int $id): array => ['InstanzID' => $id, 'Aktiv' => true], + array_values($stationen) + ), JSON_THROW_ON_ERROR), + ] + ); + + $anforderungSenden = static function ( + int $stationId, + int $leistung, + string $betriebsart = 'PV' + ) use ($managerId): void { + IPS_RequestAction($stationId, 'ManagerdatenEmpfangen', json_encode([ + 'Kopf' => [ + 'Version' => '4.0', + 'AbsenderID' => $managerId, + 'EmpfaengerID' => $stationId, + 'Zeitpunkt' => time(), + ], + 'Betriebsart' => $betriebsart, + 'Sollleistung_W' => $leistung, + ], JSON_THROW_ON_ERROR)); + }; + + $test->runCase('Alle drei Geraetevarianten erkennen Fahrzeug und Phasen', static function ( + TestContext $test + ) use ($definitionen, $stationen): void { + foreach ($stationen as $name => $stationId) { + IPS_RequestAction($stationId, 'Aktiv', true); + $test->assertSame(true, GetValue($test->objectByIdent('FahrzeugVerbunden', $stationId))); + $test->assertSame( + $definitionen[$name]['Phasenzahl'], + GetValue($test->objectByIdent('Phasenzahl', $stationId)) + ); + $test->assertSame(false, GetValue($test->objectByIdent('FahrzeugGeladen', $stationId))); + IPS_RequestAction($stationId, 'Solarladen', false); + $befehl = (string) GetValue($test->objectByIdent('LetzterGeraetebefehl', $stationId)); + $diagnose = json_encode([ + 'Aktiv' => GetValue($test->objectByIdent('Aktiv', $stationId)), + 'Ladefreigabe' => GetValue($test->objectByIdent('Ladefreigabe', $stationId)), + 'Solarladen' => GetValue($test->objectByIdent('Solarladen', $stationId)), + 'FahrzeugGeladen' => GetValue($test->objectByIdent('FahrzeugGeladen', $stationId)), + 'Phasenzahl' => GetValue($test->objectByIdent('Phasenzahl', $stationId)), + 'Status' => IPS_GetInstance($stationId)['InstanceStatus'], + 'Stoertext' => GetValue($test->objectByIdent('Stoertext', $stationId)), + ], JSON_THROW_ON_ERROR); + $test->assertTrue(str_contains( + $befehl, + $definitionen[$name]['BefehlUrlTeil'] + ), $name . ': Der erwartete 16-A-Geraetebefehl fehlt. Erhalten: ' . $befehl + . '; Diagnose: ' . $diagnose); + } + }); + + $test->runCase('Solar- und Peakbetrieb liefern die Enelix-1-Angebote', static function ( + TestContext $test + ) use ($stationen, $anforderungSenden): void { + $stationId = $stationen['go-e alt']; + IPS_RequestAction($stationId, 'Solarladen', true); + $anforderungSenden($stationId, 4150); + $befehl = (string) GetValue($test->objectByIdent('LetzterGeraetebefehl', $stationId)); + $diagnose = json_encode([ + 'Sollleistung' => GetValue($test->objectByIdent('Sollleistung', $stationId)), + 'SollwertGueltig' => GetValue($test->objectByIdent('SollwertGueltig', $stationId)), + 'Status' => IPS_GetInstance($stationId)['InstanceStatus'], + 'Stoertext' => GetValue($test->objectByIdent('Stoertext', $stationId)), + ], JSON_THROW_ON_ERROR); + $test->assertTrue(str_contains( + $befehl, + 'amp=6' + ), 'Solarladen: Der 6-A-Befehl fehlt. Erhalten: ' . $befehl . '; Diagnose: ' . $diagnose); + + IPS_RequestAction($stationId, 'Solarladen', false); + $angebot = (string) GetValue($test->objectByIdent('LeistungsangebotDiagnose', $stationId)); + $test->assertSame('[11000]', $angebot, 'PV ohne Solarladen muss nur das Maximum anbieten.'); + $abgewiesen = false; + try { + $anforderungSenden($stationId, 4150); + } catch (Throwable $fehler) { + $abgewiesen = true; + } + if (!$abgewiesen) { + $abgewiesen = GetValue($test->objectByIdent('SollwertGueltig', $stationId)) !== true + || GetValue($test->objectByIdent('Sollleistung', $stationId)) !== 4150; + } + $test->assertSame(true, $abgewiesen, 'Normalbetrieb muss eine Zwischenstufe ablehnen.'); + + $anforderungSenden($stationId, 4150, 'Peak'); + $test->assertSame( + '[0,4150,4850,5550,6250,6950,7600,8300,9000,9700,10300,11000]', + (string) GetValue($test->objectByIdent('LeistungsangebotDiagnose', $stationId)) + ); + $test->assertTrue(str_contains( + (string) GetValue($test->objectByIdent('LetzterGeraetebefehl', $stationId)), + 'amp=6' + ), 'Peak ohne Solarladen muss die Ladestufen annehmen.'); + + IPS_RequestAction($stationId, 'Solarladen', true); + $anforderungSenden($stationId, 0, 'Peak'); + $test->assertSame( + '[0]', + (string) GetValue($test->objectByIdent('LeistungsangebotDiagnose', $stationId)) + ); + $test->assertTrue(str_contains( + (string) GetValue($test->objectByIdent('LetzterGeraetebefehl', $stationId)), + 'alw=0' + ), 'Peak mit Solarladen muss die Ladestation stoppen.'); + }); +}; diff --git a/tests/Symcon/modules/Manager.php b/tests/Symcon/modules/Manager.php index 1ba0ef8..31ea31f 100644 --- a/tests/Symcon/modules/Manager.php +++ b/tests/Symcon/modules/Manager.php @@ -44,9 +44,33 @@ return static function (TestContext $test): void { ] ); + $pufferTemperaturId = $test->createVariable(2, 'Puffertemperatur', 52.0); + $aussenTemperaturId = $test->createVariable(2, 'Aussentemperatur', 5.0); + $pufferKontakte = [ + $test->createActionVariable('Pufferspeicher Stufe 1'), + $test->createActionVariable('Pufferspeicher Stufe 2'), + ]; + $pufferspeicherId = $test->createInstance( + '{C92D5EEF-9632-47A5-9659-4B02BF40FBE9}', + 'Pufferspeicher', + [ + 'PufferfuehlerVariableID' => $pufferTemperaturId, + 'AussentemperaturVariableID' => $aussenTemperaturId, + 'LeistungsStufen' => json_encode([ + ['Stufe' => 1, 'Leistung' => 1000, 'Schaltkontakt_Stufe' => $pufferKontakte[0]], + ['Stufe' => 2, 'Leistung' => 2000, 'Schaltkontakt_Stufe' => $pufferKontakte[1]], + ], JSON_THROW_ON_ERROR), + 'LastwechselSperrzeit' => 0, + 'PrioritaetPV' => 2, + 'PrioritaetPeak' => 2, + 'DiagnosevariablenAnzeigen' => true, + ] + ); + $zuordnung = [ ['InstanzID' => $einStufigId, 'Aktiv' => true], ['InstanzID' => $warmwasserId, 'Aktiv' => true], + ['InstanzID' => $pufferspeicherId, 'Aktiv' => true], ]; $lizenzcode = trim((string) getenv('ENELIX_TEST_LICENSE_CODE')); $managerId = $test->createInstance( @@ -67,8 +91,8 @@ return static function (TestContext $test): void { ] ); - $melden = static function () use ($einStufigId, $warmwasserId): void { - foreach ([$einStufigId, $warmwasserId] as $instanzId) { + $melden = static function () use ($einStufigId, $warmwasserId, $pufferspeicherId): void { + foreach ([$einStufigId, $warmwasserId, $pufferspeicherId] as $instanzId) { IPS_RequestAction($instanzId, 'Aktiv', true); IPS_RequestAction($instanzId, 'Melden', true); } @@ -77,10 +101,12 @@ return static function (TestContext $test): void { $netzleistungId, $managerId, $einStufigId, - $warmwasserId + $warmwasserId, + $pufferspeicherId ): void { IPS_RequestAction($einStufigId, 'Melden', true); IPS_RequestAction($warmwasserId, 'Melden', true); + IPS_RequestAction($pufferspeicherId, 'Melden', true); SetValue($netzleistungId, $netzleistung); IPS_RequestAction($managerId, 'Regeln', true); }; @@ -88,6 +114,8 @@ return static function (TestContext $test): void { (bool) GetValue($einKontaktId), (bool) GetValue($warmKontakte[0]), (bool) GetValue($warmKontakte[1]), + (bool) GetValue($pufferKontakte[0]), + (bool) GetValue($pufferKontakte[1]), ]; $warteAufKontakte = static function (array $erwartet, string $schritt) use ( $test, @@ -150,20 +178,20 @@ return static function (TestContext $test): void { $test->runCase('Manager und alle vorhandenen Verbrauchertypen sind verknüpft', static function ( TestContext $test - ) use ($managerId, $einStufigId, $warmwasserId, $melden): void { + ) use ($managerId, $einStufigId, $warmwasserId, $pufferspeicherId, $melden): void { IPS_RequestAction($managerId, 'Aktiv', true); $melden(); IPS_RequestAction($managerId, 'Regeln', true); - foreach ([$managerId, $einStufigId, $warmwasserId] as $instanzId) { + foreach ([$managerId, $einStufigId, $warmwasserId, $pufferspeicherId] as $instanzId) { $test->assertInstanceStatus($instanzId); } $test->assertSame( - 2, + 3, GetValue($test->objectByIdent('VerbraucherAnzahl', $managerId)) ); $test->assertSame( - 2, + 3, GetValue($test->objectByIdent('VerbraucherVerfuegbar', $managerId)) ); }); @@ -172,22 +200,22 @@ return static function (TestContext $test): void { TestContext $test ) use ($managerId, $regeln, $warteAufKontakte): void { $regeln(-3000.0); - $warteAufKontakte([true, false, true], 'Netz -3000 W'); + $warteAufKontakte([true, false, true, false, false], 'Netz -3000 W'); $test->assertSame('PV', GetValue($test->objectByIdent('Betriebsart', $managerId))); $regeln(-1000.0); - $warteAufKontakte([true, false, true], 'Netz -1000 W'); + $warteAufKontakte([true, false, true, false, false], 'Netz -1000 W'); $regeln(0.0); - $warteAufKontakte([true, false, true], 'Netz 0 W'); + $warteAufKontakte([true, false, true, false, false], 'Netz 0 W'); IPS_Sleep(1100); $regeln(1000.0); - $warteAufKontakte([true, true, false], 'Netz +1000 W'); + $warteAufKontakte([false, false, false, false, false], 'Netz +1000 W'); $test->assertSame('Peak', GetValue($test->objectByIdent('Betriebsart', $managerId))); IPS_Sleep(1100); $regeln(3000.0); - $warteAufKontakte([false, false, false], 'Netz +3000 W'); + $warteAufKontakte([false, false, false, false, false], 'Netz +3000 W'); }); $test->runCase('Keep-alive ist aktiv und erneuert unveränderte Sollwerte', static function ( @@ -216,7 +244,7 @@ return static function (TestContext $test): void { $melden(); IPS_RequestAction($managerId, 'Regeln', false); $daten = $verbraucherstatus(); - $test->assertSame(2, count($daten['Verbraucher'] ?? [])); + $test->assertSame(3, count($daten['Verbraucher'] ?? [])); SetValue($temperaturId, 53.0); $test->waitUntil(static function () use ( diff --git a/tests/Symcon/modules/Pufferspeicher.php b/tests/Symcon/modules/Pufferspeicher.php new file mode 100644 index 0000000..1ba5540 --- /dev/null +++ b/tests/Symcon/modules/Pufferspeicher.php @@ -0,0 +1,155 @@ +createVariable(2, 'Puffertemperatur', 40.0, null, '~Temperature'); + $aussenId = $test->createVariable(2, 'Aussentemperatur', 5.0, null, '~Temperature'); + $wpSollId = $test->createVariable(2, 'WP Solltemperatur', 50.0, null, '~Temperature'); + $kontakte = [ + $test->createActionVariable('Puffer Stufe 1'), + $test->createActionVariable('Puffer Stufe 2'), + ]; + $stufen = [ + ['Stufe' => 1, 'Leistung' => 1000, 'Schaltkontakt_Stufe' => $kontakte[0]], + ['Stufe' => 2, 'Leistung' => 2000, 'Schaltkontakt_Stufe' => $kontakte[1]], + ]; + $verbraucherId = $test->createInstance( + '{C92D5EEF-9632-47A5-9659-4B02BF40FBE9}', + 'Pufferspeicher', + [ + 'PufferfuehlerVariableID' => $pufferId, + 'AussentemperaturVariableID' => $aussenId, + 'WaermepumpenSolltemperaturVariableID' => $wpSollId, + 'LeistungsStufen' => json_encode($stufen, JSON_THROW_ON_ERROR), + 'FusspunktVorlauftemperatur' => 35.0, + 'HeizkurvenSteigung' => 1.0, + 'HeizkurveMinimaltemperatur' => 20.0, + 'HeizkurveMaximaltemperatur' => 80.0, + 'Hysterese' => 5.0, + 'LastwechselSperrzeit' => 0, + 'TemperaturMaxAlter' => 120, + 'DiagnosevariablenAnzeigen' => true, + ] + ); + $managerId = $test->createInstance( + '{6F771B18-59D4-4C8A-B951-3B2FE9F6A2C4}', + 'Hilfsmanager Pufferspeicher', + [ + 'AutomatischeSuche' => false, + 'VerbraucherZuordnung' => json_encode([ + ['InstanzID' => $verbraucherId, 'Aktiv' => true], + ], JSON_THROW_ON_ERROR), + ] + ); + + $managerdatenSenden = static function ( + string $betriebsart, + ?int $leistung + ) use ($managerId, $verbraucherId): void { + IPS_RequestAction($verbraucherId, 'ManagerdatenEmpfangen', json_encode([ + 'Kopf' => [ + 'Version' => '4.0', + 'AbsenderID' => $managerId, + 'EmpfaengerID' => $verbraucherId, + 'Zeitpunkt' => time(), + ], + 'Betriebsart' => $betriebsart, + 'Sollleistung_W' => $leistung, + ], JSON_THROW_ON_ERROR)); + }; + $kontaktzustand = static fn (): array => array_map( + static fn (int $id): bool => (bool) GetValue($id), + $kontakte + ); + + $test->runCase('Instanz und Variablen werden angelegt', static function ( + TestContext $test + ) use ($verbraucherId): void { + $test->assertInstanceStatus($verbraucherId); + foreach ([ + 'Aktiv', + 'Puffertemperatur', + 'Aussentemperatur', + 'Solltemperatur', + 'Heizbedarf', + 'Istleistung', + 'Sollleistung', + 'AktiveStufe', + 'TemperaturenGueltig', + ] as $ident) { + $test->objectByIdent($ident, $verbraucherId); + } + }); + + $test->runCase('Unter der Einschaltschwelle kann PV eine Stufe schalten', static function ( + TestContext $test + ) use ($verbraucherId, $managerdatenSenden, $kontaktzustand): void { + IPS_RequestAction($verbraucherId, 'Aktiv', true); + $managerdatenSenden('PV', null); + $test->assertSame(true, GetValue($test->objectByIdent('Heizbedarf', $verbraucherId))); + + $managerdatenSenden('PV', 2000); + $test->waitUntil(static fn (): bool => $kontaktzustand() === [false, true]); + $test->assertSame([false, true], $kontaktzustand()); + }); + + $test->runCase('In der Hysterese und oberhalb des Sollwerts wird ausgeschaltet', static function ( + TestContext $test + ) use ($pufferId, $verbraucherId, $kontaktzustand): void { + SetValue($pufferId, 47.0); + IPS_RequestAction($verbraucherId, 'Melden', true); + $test->waitUntil(static fn (): bool => $kontaktzustand() === [false, false]); + $test->assertSame(false, GetValue($test->objectByIdent('Heizbedarf', $verbraucherId))); + + SetValue($pufferId, 55.0); + IPS_RequestAction($verbraucherId, 'Melden', true); + $test->assertSame([false, false], $kontaktzustand()); + }); + + $test->runCase('Peakbetrieb sperrt den Pufferspeicher unabhaengig von der Temperatur', static function ( + TestContext $test + ) use ($pufferId, $verbraucherId, $managerdatenSenden, $kontaktzustand): void { + SetValue($pufferId, 20.0); + $managerdatenSenden('Peak', null); + $test->assertSame(false, GetValue($test->objectByIdent('Heizbedarf', $verbraucherId))); + $test->waitUntil(static fn (): bool => $kontaktzustand() === [false, false]); + + $abgewiesen = false; + try { + $managerdatenSenden('Peak', 1000); + } catch (Throwable $fehler) { + $abgewiesen = true; + } + if (!$abgewiesen) { + $abgewiesen = GetValue($test->objectByIdent('SollwertGueltig', $verbraucherId)) !== true + || GetValue($test->objectByIdent('Sollleistung', $verbraucherId)) !== 1000; + } + $test->assertSame(true, $abgewiesen, 'Peak darf keine Heizstufe anbieten.'); + $test->assertSame([false, false], $kontaktzustand()); + }); + + $test->runCase('Lokales Ausschalten schaltet jede Stufe aus', static function ( + TestContext $test + ) use ($verbraucherId, $kontaktzustand): void { + IPS_RequestAction($verbraucherId, 'Aktiv', false); + $test->waitUntil(static fn (): bool => $kontaktzustand() === [false, false]); + $test->assertSame([false, false], $kontaktzustand()); + }); + + $test->runCase('WP-Button kalibriert den Heizkurven-Fusspunkt', static function ( + TestContext $test + ) use ($verbraucherId): void { + IPS_SetProperty($verbraucherId, 'FusspunktVorlauftemperatur', 30.0); + IPS_ApplyChanges($verbraucherId); + IPS_RequestAction($verbraucherId, 'HeizkurveVonWaermepumpeUebernehmen', 0); + + $test->assertEquals( + 35.0, + (float) IPS_GetProperty($verbraucherId, 'FusspunktVorlauftemperatur'), + 0.001 + ); + }); +}; diff --git a/tests/Symcon/modules/VerbraucherEinStufig.php b/tests/Symcon/modules/VerbraucherEinStufig.php index 1be20f2..071140f 100644 --- a/tests/Symcon/modules/VerbraucherEinStufig.php +++ b/tests/Symcon/modules/VerbraucherEinStufig.php @@ -32,17 +32,21 @@ return static function (TestContext $test): void { ] ); - $anforderungSenden = static function (int $leistung) use ( + $anforderungSenden = static function ( + ?int $leistung, + string $betriebsart = 'PV' + ) use ( $managerId, $verbraucherId ): void { $paket = [ 'Kopf' => [ - 'Version' => '3.0', + 'Version' => '4.0', 'AbsenderID' => $managerId, 'EmpfaengerID' => $verbraucherId, 'Zeitpunkt' => time(), ], + 'Betriebsart' => $betriebsart, 'Sollleistung_W' => $leistung, ]; IPS_RequestAction( @@ -64,6 +68,28 @@ return static function (TestContext $test): void { } }); + $test->runCase('PV und Peak liefern unterschiedliche Leistungsangebote', static function ( + TestContext $test + ) use ($verbraucherId, $anforderungSenden): void { + IPS_RequestAction($verbraucherId, 'Aktiv', true); + IPS_Sleep(2100); + $anforderungSenden(null, 'Peak'); + + $abgewiesen = false; + try { + $anforderungSenden(1200, 'Peak'); + } catch (Throwable $fehler) { + $abgewiesen = true; + } + if (!$abgewiesen) { + $abgewiesen = GetValue($test->objectByIdent('SollwertGueltig', $verbraucherId)) !== true + || GetValue($test->objectByIdent('Sollleistung', $verbraucherId)) !== 1200; + } + $test->assertSame(true, $abgewiesen, 'Peak darf die Nennleistung nicht anbieten.'); + + $anforderungSenden(null, 'PV'); + }); + $test->runCase('Nur angebotene Leistungsanforderungen werden angenommen', static function ( TestContext $test ) use ( @@ -143,7 +169,7 @@ return static function (TestContext $test): void { SetValue($rueckmeldungId, true); }); - $test->runCase('Tägliche Mindestlaufzeit wird spätestens rechtzeitig erzwungen', static function ( + $test->runCase('Peak kann die Tagesmindestlaufzeit ohne angebotene Sperre erzwingen', static function ( TestContext $test ) use ( $verbraucherId, @@ -155,7 +181,13 @@ return static function (TestContext $test): void { $anforderungSenden(0); $test->waitUntil(static fn (): bool => GetValue($kontaktId) === false); SetValue($rueckmeldungId, false); + $test->waitUntil(static fn (): bool => GetValue( + $test->objectByIdent('Schaltzustand', $verbraucherId) + ) === false); + IPS_Sleep(2100); + $anforderungSenden(null, 'Peak'); + IPS_SetProperty($verbraucherId, 'PeakSperreBeiMindestlaufzeitAnbieten', false); IPS_SetProperty($verbraucherId, 'Mindestlaufzeit', 86400); IPS_ApplyChanges($verbraucherId); $test->waitUntil(static fn (): bool => GetValue($kontaktId) === true); @@ -163,9 +195,14 @@ return static function (TestContext $test): void { $test->objectByIdent('Sollleistung', $verbraucherId) )); SetValue($rueckmeldungId, true); + $test->waitUntil(static fn (): bool => GetValue( + $test->objectByIdent('Schaltzustand', $verbraucherId) + ) === true); IPS_SetProperty($verbraucherId, 'Mindestlaufzeit', 0); + IPS_SetProperty($verbraucherId, 'PeakSperreBeiMindestlaufzeitAnbieten', true); IPS_ApplyChanges($verbraucherId); + $anforderungSenden(null, 'PV'); }); $test->runCase('Physische Rückmeldung bestätigt und überwacht den Zustand', static function ( diff --git a/tests/Symcon/modules/Warmwassererwaermer.php b/tests/Symcon/modules/Warmwassererwaermer.php index 917b9e2..d9a42f0 100644 --- a/tests/Symcon/modules/Warmwassererwaermer.php +++ b/tests/Symcon/modules/Warmwassererwaermer.php @@ -38,11 +38,13 @@ return static function (TestContext $test): void { 'DiagnosevariablenAnzeigen' => true, ] ); + $lizenzcode = trim((string) getenv('ENELIX_TEST_LICENSE_CODE')); $managerId = $test->createInstance( '{6F771B18-59D4-4C8A-B951-3B2FE9F6A2C4}', 'Hilfsmanager', [ 'NetzleistungVariableID' => $netzleistungId, + 'Lizenzcode' => $lizenzcode, 'AutomatischeSuche' => false, 'VerbraucherZuordnung' => json_encode([ ['InstanzID' => $verbraucherId, 'Aktiv' => true], @@ -88,6 +90,17 @@ return static function (TestContext $test): void { IPS_RequestAction($managerId, 'Regeln', true); }; + if ($lizenzcode === '') { + $test->runCase('Manager bleibt ohne Lizenz sicher gesperrt', static function ( + TestContext $test + ) use ($verbraucherId, $managerId): void { + $test->assertInstanceStatus($verbraucherId); + $test->assertInstanceStatus($managerId, 203); + }); + + return; + } + $test->runCase('Instanzen und Diagnosevariablen werden angelegt', static function ( TestContext $test ) use ($verbraucherId, $managerId): void { @@ -119,7 +132,7 @@ return static function (TestContext $test): void { ); }); - $test->runCase('Unter Minimum wird in PV und Peak bis über die Hysterese geheizt', static function ( + $test->runCase('Unter Minimum erzwingt PV das Maximum und Peak bietet alle Stufen', static function ( TestContext $test ) use ( $temperaturSetzen, @@ -132,17 +145,19 @@ return static function (TestContext $test): void { $warteAufKontakte([false, false, true]); $test->assertSame('PV', GetValue($test->objectByIdent('Betriebsart', $managerId))); - $managerRegeln(3000.0); - $warteAufKontakte([false, false, true]); + IPS_Sleep(1100); + $managerRegeln(1000.0); + $warteAufKontakte([false, true, false]); $test->assertSame('Peak', GetValue($test->objectByIdent('Betriebsart', $managerId))); - $temperaturSetzen(49.9); - $managerRegeln(3000.0); - $warteAufKontakte([false, false, true]); + IPS_Sleep(1100); + $temperaturSetzen(47.0); + $managerRegeln(1000.0); + $warteAufKontakte([true, false, false]); IPS_Sleep(1100); $temperaturSetzen(50.0); - $managerRegeln(3000.0); + $managerRegeln(1000.0); $warteAufKontakte([false, false, false]); }); @@ -170,12 +185,12 @@ return static function (TestContext $test): void { $managerRegeln(-3000.0); $warteAufKontakte([false, false, false]); - $temperaturSetzen(55.0); + $temperaturSetzen(54.9); $managerRegeln(-3000.0); $warteAufKontakte([false, false, true]); }); - $test->runCase('Legionellenziel erlaubt Peak-Stufen und lädt in PV maximal', static function ( + $test->runCase('Erhöhtes Maximalziel bleibt im freien Peakbetrieb gesperrt', static function ( TestContext $test ) use ( $verbraucherId, @@ -190,10 +205,10 @@ return static function (TestContext $test): void { IPS_Sleep(1100); $managerRegeln(2000.0); - $warteAufKontakte([true, false, false]); + $warteAufKontakte([false, false, false]); }); - $test->runCase('Zeitplan erzwingt rechtzeitiges Aufheizen wie ein Minimum', static function ( + $test->runCase('Zeitplan bietet in Peak alle Stufen und erzwingt in PV das Maximum', static function ( TestContext $test ) use ( $verbraucherId, @@ -211,6 +226,12 @@ return static function (TestContext $test): void { ['Uhrzeit' => date('H:i', time() + 60), 'Solltemperatur' => 60], ], JSON_THROW_ON_ERROR)); IPS_ApplyChanges($verbraucherId); + $test->assertSame(true, GetValue( + $test->objectByIdent('AenderungMoeglich', $verbraucherId) + )); + $warteAufKontakte([false, false, false]); + + $managerRegeln(-3000.0); $warteAufKontakte([false, false, true]); }); }; diff --git a/tests/SymconTestContractTest.php b/tests/SymconTestContractTest.php index 2f69c51..b6d6d08 100644 --- a/tests/SymconTestContractTest.php +++ b/tests/SymconTestContractTest.php @@ -58,7 +58,7 @@ final class SymconTestContractTest extends TestCase public function testAffectedModuleResolverSelectsAllMatches(): void { self::assertSame( - ['Manager', 'VerbraucherEinStufig', 'Warmwassererwaermer'], + ['LadestationStandAlone', 'Manager', 'Pufferspeicher', 'VerbraucherEinStufig', 'Warmwassererwaermer'], $this->resolveAffectedModules(['libs/Nachrichtenvertrag.php']) ); } @@ -74,7 +74,7 @@ final class SymconTestContractTest extends TestCase public function testAffectedModuleResolverSelectsAllModulesForFrameworkChanges(): void { self::assertSame( - ['Manager', 'VerbraucherEinStufig', 'Warmwassererwaermer'], + ['LadestationStandAlone', 'Manager', 'Pufferspeicher', 'VerbraucherEinStufig', 'Warmwassererwaermer'], $this->resolveAffectedModules(['tests/Symcon/TestContext.php']) ); } diff --git a/tests/VerbraucherEinStufig/EinStufigReglerTest.php b/tests/VerbraucherEinStufig/EinStufigReglerTest.php index ba20a3c..7ee63e2 100644 --- a/tests/VerbraucherEinStufig/EinStufigReglerTest.php +++ b/tests/VerbraucherEinStufig/EinStufigReglerTest.php @@ -16,23 +16,43 @@ final class EinStufigReglerTest extends TestCase { self::assertSame( [0, 3500], - EinStufigRegler::leistungsangebot(3500, 0, true, true, false) + EinStufigRegler::leistungsangebot(3500, 0, true, true, false, false) ); } - public function testGesperrtesOderErzwungenesAngebotEnthaeltNurIstleistung(): void + public function testPeakbetriebBietetBeiFreierSchaltungNurAus(): void + { + self::assertSame( + [0], + EinStufigRegler::leistungsangebot(3500, 0, true, true, false, true) + ); + } + + public function testTechnischGesperrtesAngebotEnthaeltNurIstleistung(): void { self::assertSame( [3500], - EinStufigRegler::leistungsangebot(3500, 3500, true, false, false) - ); - self::assertSame( - [0], - EinStufigRegler::leistungsangebot(3500, 0, true, true, true) + EinStufigRegler::leistungsangebot(3500, 3500, true, false, false, true) ); self::assertSame( [3500], - EinStufigRegler::leistungsangebot(3500, 3500, false, true, false) + EinStufigRegler::leistungsangebot(3500, 3500, false, true, false, true) + ); + } + + public function testPeakSperreBeiMindestlaufzeitIstKonfigurierbar(): void + { + self::assertSame( + [0, 3500], + EinStufigRegler::leistungsangebot(3500, 0, true, true, true, true, true) + ); + self::assertSame( + [3500], + EinStufigRegler::leistungsangebot(3500, 0, true, true, true, true, false) + ); + self::assertSame( + [3500], + EinStufigRegler::leistungsangebot(3500, 0, true, true, true, false, true) ); } @@ -40,7 +60,7 @@ final class EinStufigReglerTest extends TestCase { $this->expectException(InvalidArgumentException::class); - EinStufigRegler::leistungsangebot(3500, 1200, true, true, false); + EinStufigRegler::leistungsangebot(3500, 1200, true, true, false, false); } public function testFreigabeVerwendetZustandsabhaengigeMindestdauer(): void diff --git a/tests/VerbraucherEinStufig/ModulstrukturTest.php b/tests/VerbraucherEinStufig/ModulstrukturTest.php index 3b80a1b..e86db0c 100644 --- a/tests/VerbraucherEinStufig/ModulstrukturTest.php +++ b/tests/VerbraucherEinStufig/ModulstrukturTest.php @@ -65,6 +65,7 @@ final class VerbraucherEinStufigModulstrukturTest extends TestCase 'SchaltkontaktInvertiert', 'RueckmeldungVariableID', 'Mindestlaufzeit', + 'PeakSperreBeiMindestlaufzeitAnbieten', 'Mindesteinschaltdauer', 'Mindestausschaltdauer', 'DiagnosevariablenAnzeigen', @@ -130,7 +131,7 @@ final class VerbraucherEinStufigModulstrukturTest extends TestCase ); } - public function testModulVerwendetAusschliesslichDenVertrag30(): void + public function testModulVerwendetVertrag40UndBetriebsartabhaengigesAngebot(): void { $modul = (string) file_get_contents(self::MODULPFAD . 'module.php'); @@ -138,6 +139,9 @@ final class VerbraucherEinStufigModulstrukturTest extends TestCase self::assertStringContainsString('ManagerdatenEmpfangen(array $daten)', $modul); self::assertStringContainsString('Nachrichtenvertrag::pruefeManagerdaten', $modul); self::assertStringContainsString('Nachrichtenvertrag::pruefeVerbraucherdaten', $modul); + self::assertStringContainsString("['Betriebsart']", $modul); + self::assertStringContainsString('BETRIEBSART_PEAK', $modul); + self::assertStringContainsString("'Betriebsart' =>", $modul); self::assertStringNotContainsString("RegisterVariableString('PowerSteps'", $modul); self::assertStringNotContainsString("RegisterPropertyInteger('ManagerID'", $modul); } diff --git a/tests/WarmwasserReglerTest.php b/tests/WarmwasserReglerTest.php index b00cfda..9a0bc2e 100644 --- a/tests/WarmwasserReglerTest.php +++ b/tests/WarmwasserReglerTest.php @@ -31,6 +31,46 @@ final class WarmwasserReglerTest extends TestCase self::assertSame(0, WarmwasserRegler::stufeFuerLeistung($stufen, 0)); } + public function testPeakBietetUnterMinimumUndBeiAktiverUntererHystereseAlleStufen(): void + { + $werte = [0, 1000, 2000]; + + self::assertSame( + $werte, + WarmwasserRegler::leistungsangebot(true, 44.0, 50.0, 60.0, 5.0, false, $werte) + ); + self::assertSame( + $werte, + WarmwasserRegler::leistungsangebot(true, 52.0, 50.0, 60.0, 5.0, true, $werte) + ); + self::assertSame( + [0], + WarmwasserRegler::leistungsangebot(true, 52.0, 50.0, 60.0, 5.0, false, $werte) + ); + } + + public function testPvErzwingtUnterMinimumMaximumUndBietetSonstZustandsabhaengigAn(): void + { + $werte = [0, 1000, 2000]; + + self::assertSame( + [2000], + WarmwasserRegler::leistungsangebot(false, 44.0, 50.0, 60.0, 5.0, false, $werte) + ); + self::assertSame( + $werte, + WarmwasserRegler::leistungsangebot(false, 52.0, 50.0, 60.0, 5.0, false, $werte) + ); + self::assertSame( + $werte, + WarmwasserRegler::leistungsangebot(false, 58.0, 50.0, 60.0, 5.0, true, $werte) + ); + self::assertSame( + [0], + WarmwasserRegler::leistungsangebot(false, 58.0, 50.0, 60.0, 5.0, false, $werte) + ); + } + public function testDoppelteKontakteWerdenAbgewiesen(): void { $this->expectException(InvalidArgumentException::class);