From 7845c66fd84cdd09b8c9f94599ae7d6429edd8cb Mon Sep 17 00:00:00 2001 From: dh_Agent Date: Tue, 22 Sep 2026 14:34:23 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20W=C3=A4rmepumpenmodul=20integrieren?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Manager/module.php | 1 + README.md | 2 +- Waermepumpe/form.json | 166 ++++ Waermepumpe/module.json | 14 + Waermepumpe/module.php | 1221 ++++++++++++++++++++++++ docs/module/README.md | 6 +- docs/module/Waermepumpe/README.md | 110 ++- libs/WaermepumpenRegler.php | 190 ++++ tests/DokumentationsstrukturTest.php | 18 +- tests/Symcon/manifest.php | 10 + tests/Symcon/modules/Waermepumpe.php | 140 +++ tests/SymconTestContractTest.php | 2 + tests/WaermepumpeModulstrukturTest.php | 136 +++ tests/WaermepumpenReglerTest.php | 162 ++++ 14 files changed, 2127 insertions(+), 51 deletions(-) create mode 100644 Waermepumpe/form.json create mode 100644 Waermepumpe/module.json create mode 100644 Waermepumpe/module.php create mode 100644 libs/WaermepumpenRegler.php create mode 100644 tests/Symcon/modules/Waermepumpe.php create mode 100644 tests/WaermepumpeModulstrukturTest.php create mode 100644 tests/WaermepumpenReglerTest.php diff --git a/Manager/module.php b/Manager/module.php index 789cb6e..8944ff5 100644 --- a/Manager/module.php +++ b/Manager/module.php @@ -24,6 +24,7 @@ class Manager extends IPSModule implements ManagerSchnittstelle private const VERBRAUCHER_MODUL_IDS = [ '{B7C54AF4-AD7D-4FE4-B75D-203693906251}', '{15879A4E-D0C2-4495-83DE-46E1E462591E}', + '{A31C9274-54F7-4BD2-9804-7AF23E86C4D1}', '{0D94913C-0F31-4C29-A685-6EB4AE58E55D}', '{32F65988-BE55-4FD6-9FFF-CF1C4C8628F8}', '{C92D5EEF-9632-47A5-9659-4B02BF40FBE9}', diff --git a/README.md b/README.md index 150f42a..5b2fe14 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ weiteren Module sind als Diskussionsentwürfe dokumentiert. - Wassererwaermer (implementiert) - Pufferspeicher - Verbraucher 1-Stufig (implementiert) -- Waermepumpe +- Waermepumpe (implementiert) - Ladestation Stand-Alone (implementiert) - Ladestation Gateway - Easee Gateway diff --git a/Waermepumpe/form.json b/Waermepumpe/form.json new file mode 100644 index 0000000..ca28fa7 --- /dev/null +++ b/Waermepumpe/form.json @@ -0,0 +1,166 @@ +{ + "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": "ExpansionPanel", + "caption": "Waermepumpe und Kontakte", + "expanded": true, + "items": [ + { + "type": "Select", + "name": "Kontaktart", + "caption": "Kontaktart", + "options": [ + {"caption": "Sperre und Erhoehung", "value": 0}, + {"caption": "SG Ready", "value": 1} + ] + }, + { + "type": "SelectVariable", + "name": "Kontakt1VariableID", + "caption": "Kontakt 1 (Sperre / SG Ready 1)", + "validVariableTypes": [0] + }, + { + "type": "CheckBox", + "name": "Kontakt1Invertiert", + "caption": "Kontakt 1 invertieren" + }, + { + "type": "SelectVariable", + "name": "Kontakt2VariableID", + "caption": "Kontakt 2 (Erhoehung / SG Ready 2)", + "validVariableTypes": [0] + }, + { + "type": "CheckBox", + "name": "Kontakt2Invertiert", + "caption": "Kontakt 2 invertieren" + }, + { + "type": "NumberSpinner", + "name": "Nennleistung", + "caption": "Nennleistung", + "suffix": " W", + "minimum": 1 + } + ] + }, + { + "type": "ExpansionPanel", + "caption": "Betriebsrueckmeldung", + "expanded": true, + "items": [ + { + "type": "Select", + "name": "Rueckmeldungsart", + "caption": "Rueckmeldungsart", + "options": [ + {"caption": "Gemessene Leistung", "value": 0}, + {"caption": "Betriebsstatus", "value": 1} + ] + }, + { + "type": "SelectVariable", + "name": "IstleistungVariableID", + "caption": "Leistung oder Betriebsstatus", + "validVariableTypes": [0, 1, 2] + }, + { + "type": "NumberSpinner", + "name": "Laufschwelle", + "caption": "Laufschwelle bei Leistungsmessung", + "suffix": " W", + "minimum": 0, + "digits": 1 + } + ] + }, + { + "type": "ExpansionPanel", + "caption": "Schutzzeiten", + "expanded": true, + "items": [ + { + "type": "NumberSpinner", + "name": "Anlaufwartezeit", + "caption": "Anlaufwartezeit", + "suffix": " s", + "minimum": 0 + }, + { + "type": "NumberSpinner", + "name": "Wiederholsperre", + "caption": "Wiederholsperre nach erfolglosem Anlauf", + "suffix": " s", + "minimum": 0 + }, + { + "type": "NumberSpinner", + "name": "Mindestlaufzeit", + "caption": "Mindestlaufzeit", + "suffix": " s", + "minimum": 0 + }, + { + "type": "NumberSpinner", + "name": "Mindestsperrzeit", + "caption": "Mindestsperrzeit", + "suffix": " s", + "minimum": 0 + }, + { + "type": "NumberSpinner", + "name": "MaximaleSperrzeit", + "caption": "Maximale Sperrzeit am Stueck", + "suffix": " s", + "minimum": 0 + }, + { + "type": "NumberSpinner", + "name": "MaximaleSperrzeit24h", + "caption": "Maximale Sperrzeit in 24 Stunden", + "suffix": " s", + "minimum": 0, + "maximum": 86400 + } + ] + }, + { + "type": "ExpansionPanel", + "caption": "Darstellung und Diagnose", + "items": [ + { + "type": "CheckBox", + "name": "EinstellungenInVisu", + "caption": "Lokale 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": "Konfiguration ungueltig"}, + {"code": 202, "icon": "error", "caption": "Schalt- oder Rueckmeldefehler"} + ] +} diff --git a/Waermepumpe/module.json b/Waermepumpe/module.json new file mode 100644 index 0000000..b05bfbe --- /dev/null +++ b/Waermepumpe/module.json @@ -0,0 +1,14 @@ +{ + "id": "{A31C9274-54F7-4BD2-9804-7AF23E86C4D1}", + "name": "VerbraucherWaermepumpe", + "type": 3, + "vendor": "Enelix", + "aliases": [ + "Waermepumpe" + ], + "parentRequirements": [], + "childRequirements": [], + "implemented": [], + "prefix": "ENELIX", + "url": "https://git.belevo.ch/ENELIX/Enelix-EMS/src/branch/develop/Waermepumpe" +} diff --git a/Waermepumpe/module.php b/Waermepumpe/module.php new file mode 100644 index 0000000..da2a2df --- /dev/null +++ b/Waermepumpe/module.php @@ -0,0 +1,1221 @@ + */ + private const DIAGNOSE_VARIABLEN = [ + 'Istleistung', + 'Leistungsquelle', + 'Sollleistung', + 'SollwertGueltig', + 'Verfuegbar', + 'AenderungMoeglich', + 'Stoerung', + 'Stoertext', + 'AnlaufAusstehend', + 'WiederholsperreAktiv', + 'SperrerholungAktiv', + 'RestMindestlaufzeit', + 'RestMindestsperrzeit', + 'Sperrzeit24h', + ]; + + public function Create(): void + { + parent::Create(); + + $this->registriereVerbraucherBasis(); + $this->RegisterPropertyInteger('Kontaktart', WaermepumpenRegler::KONTAKTART_SPERRE_ERHOEHUNG); + $this->RegisterPropertyInteger('Kontakt1VariableID', 0); + $this->RegisterPropertyInteger('Kontakt2VariableID', 0); + $this->RegisterPropertyBoolean('Kontakt1Invertiert', false); + $this->RegisterPropertyBoolean('Kontakt2Invertiert', false); + $this->RegisterPropertyInteger('Nennleistung', 0); + $this->RegisterPropertyInteger('Rueckmeldungsart', WaermepumpenRegler::RUECKMELDUNG_LEISTUNG); + $this->RegisterPropertyInteger('IstleistungVariableID', 0); + $this->RegisterPropertyFloat('Laufschwelle', 100.0); + $this->RegisterPropertyInteger('Anlaufwartezeit', 30); + $this->RegisterPropertyInteger('Wiederholsperre', 300); + $this->RegisterPropertyInteger('Mindestlaufzeit', 1200); + $this->RegisterPropertyInteger('Mindestsperrzeit', 1200); + $this->RegisterPropertyInteger('MaximaleSperrzeit', 7200); + $this->RegisterPropertyInteger('MaximaleSperrzeit24h', 21600); + $this->RegisterPropertyBoolean('DiagnosevariablenAnzeigen', false); + + $this->RegisterVariableInteger('Waermepumpenzustand', 'Waermepumpenzustand', '', 100); + $this->RegisterVariableBoolean('WaermepumpeLaeuft', 'Waermepumpe laeuft', '~Switch', 110); + $this->RegisterVariableBoolean('SperreAktiv', 'Sperre aktiv', '~Switch', 120); + $this->RegisterVariableBoolean('ErhoehungAktiv', 'Erhoehung aktiv', '~Switch', 130); + $this->RegisterVariableInteger('SGReadyZustand', 'SG-Ready-Zustand', '', 140); + $this->RegisterVariableInteger('Laufzeit', 'Bestaetigte Laufzeit', '', 150); + + foreach (['Kontakt1', 'Kontakt2', 'Istleistung'] as $name) { + $this->RegisterAttributeInteger('Registrierte' . $name . 'ID', 0); + } + $this->RegisterAttributeInteger('LetzteManagerID', 0); + $this->RegisterAttributeInteger('LetzteVorgabeZeit', 0); + $this->RegisterAttributeString('Betriebsart', Nachrichtenvertrag::BETRIEBSART_PV); + $this->RegisterAttributeString('Leistungsangebot', '[0]'); + $this->RegisterAttributeBoolean('Initialisiert', false); + $this->RegisterAttributeBoolean('SchaltvorgangLaeuft', false); + $this->RegisterAttributeString('Schaltfehler', ''); + $this->RegisterAttributeInteger('Steuerzustand', WaermepumpenRegler::STEUERZUSTAND_NORMAL); + $this->RegisterAttributeBoolean('LetzterLaufstatus', false); + $this->RegisterAttributeInteger('Laufbeginn', 0); + $this->RegisterAttributeInteger('LaufzeitStand', 0); + $this->RegisterAttributeInteger('Sperrbeginn', 0); + $this->RegisterAttributeString('Sperrhistorie', '[]'); + $this->RegisterAttributeBoolean('Sperrerholung', false); + $this->RegisterAttributeInteger('ErholungsLaufbeginn', 0); + $this->RegisterAttributeInteger('AnlaufDeadline', 0); + $this->RegisterAttributeInteger('WiederholsperreBis', 0); + + $this->RegisterAttributeFloat('ZustandIstleistung', 0.0); + $this->RegisterAttributeInteger('ZustandLeistungsquelle', 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->RegisterAttributeBoolean('ZustandAnlaufAusstehend', false); + $this->RegisterAttributeBoolean('ZustandWiederholsperreAktiv', false); + $this->RegisterAttributeBoolean('ZustandSperrerholungAktiv', false); + $this->RegisterAttributeInteger('ZustandRestMindestlaufzeit', 0); + $this->RegisterAttributeInteger('ZustandRestMindestsperrzeit', 0); + $this->RegisterAttributeInteger('ZustandSperrzeit24h', 0); + + $this->RegisterTimer( + 'Meldezyklus', + 0, + "IPS_RequestAction(\$_IPS['TARGET'], 'Melden', false);" + ); + $this->RegisterTimer( + 'MeldungVerzoegert', + 0, + "IPS_RequestAction(\$_IPS['TARGET'], 'Melden', true);" + ); + $this->RegisterTimer( + 'Zustandspruefung', + 0, + "IPS_RequestAction(\$_IPS['TARGET'], 'Aktualisieren', 0);" + ); + $this->RegisterTimer( + 'VorgabeTimeout', + 0, + "IPS_RequestAction(\$_IPS['TARGET'], 'VorgabeTimeout', 0);" + ); + } + + public function ApplyChanges(): void + { + parent::ApplyChanges(); + + $this->aktualisiereVariablen(); + $this->registriereMeldungen(); + 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('Meldezyklus', $this->ReadPropertyInteger('Meldeintervall') * 1000); + $this->initialisiereZustand(time()); + $this->aktualisiere(true); + } + + public function MessageSink($zeitstempel, $senderID, $nachricht, $daten): void + { + if ((int) $nachricht !== self::VM_UPDATE || $this->ReadAttributeBoolean('SchaltvorgangLaeuft')) { + return; + } + + $ids = [ + $this->ReadPropertyInteger('Kontakt1VariableID'), + $this->ReadPropertyInteger('Kontakt2VariableID'), + $this->ReadPropertyInteger('IstleistungVariableID'), + ]; + if (in_array((int) $senderID, $ids, true)) { + $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->SetTimerInterval('VorgabeTimeout', 0); + } + $this->aktualisiere(true); + return; + + case 'Melden': + if ((bool) $wert) { + $this->SetTimerInterval('MeldungVerzoegert', 0); + } + $this->aktualisiere(false); + $this->sendeVerbraucherdaten(); + return; + + case 'Aktualisieren': + $this->SetTimerInterval('Zustandspruefung', 0); + $this->aktualisiere(true); + return; + + case 'VorgabeTimeout': + $this->SetTimerInterval('VorgabeTimeout', 0); + $this->aktualisiere(true); + 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 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.' + ); + } + + $this->WriteAttributeInteger('LetzteManagerID', $managerID); + $this->WriteAttributeInteger('LetzteVorgabeZeit', time()); + $this->setzeZustand('Sollleistung', $sollleistung); + $this->setzeZustand('SollwertGueltig', true); + $this->planeVorgabeTimeout(time()); + $this->aktualisiere(false); + $this->planeMeldung(); + } + + private function aktualisiere(bool $meldungPlanen): void + { + try { + $jetzt = time(); + $rueckmeldung = $this->leseRueckmeldung(); + $laeuft = WaermepumpenRegler::laeuft( + $rueckmeldung, + $this->ReadPropertyInteger('Rueckmeldungsart'), + $this->ReadPropertyFloat('Laufschwelle') + ); + $istleistung = WaermepumpenRegler::istleistung( + $rueckmeldung, + $this->ReadPropertyInteger('Rueckmeldungsart'), + $this->ReadPropertyInteger('Nennleistung') + ); + $this->pruefeKontaktzustand(); + $this->aktualisiereLaufzustand($laeuft, $jetzt); + $this->pruefeVorgabeTimeout($jetzt); + + $steuerzustand = $this->ReadAttributeInteger('Steuerzustand'); + $this->aktualisiereSperrhistorie($steuerzustand, $jetzt); + $sperrzeit24h = $this->sperrsekunden24h($steuerzustand, $jetzt); + $maxAmStueckErreicht = $steuerzustand === WaermepumpenRegler::STEUERZUSTAND_SPERRE + && $this->ReadPropertyInteger('MaximaleSperrzeit') > 0 + && $jetzt - $this->ReadAttributeInteger('Sperrbeginn') + >= $this->ReadPropertyInteger('MaximaleSperrzeit'); + $max24hErreicht = $this->ReadPropertyInteger('MaximaleSperrzeit24h') > 0 + && $sperrzeit24h >= $this->ReadPropertyInteger('MaximaleSperrzeit24h'); + if (($maxAmStueckErreicht || $max24hErreicht) + && $steuerzustand === WaermepumpenRegler::STEUERZUSTAND_SPERRE + ) { + $this->WriteAttributeBoolean('Sperrerholung', true); + $this->WriteAttributeInteger('ErholungsLaufbeginn', $laeuft ? $jetzt : 0); + $this->WriteAttributeInteger('Laufbeginn', $laeuft ? $jetzt : 0); + $this->schalte(WaermepumpenRegler::STEUERZUSTAND_NORMAL, $jetzt); + $steuerzustand = WaermepumpenRegler::STEUERZUSTAND_NORMAL; + $this->setzeZustand('SollwertGueltig', false); + } + + $this->aktualisiereSperrerholung($laeuft, $jetzt); + $sperrerholung = $this->ReadAttributeBoolean('Sperrerholung'); + $restMindestlaufzeit = $this->restMindestlaufzeit($laeuft, $jetzt); + $restMindestsperrzeit = $steuerzustand === WaermepumpenRegler::STEUERZUSTAND_SPERRE + ? WaermepumpenRegler::restzeit( + $this->ReadAttributeInteger('Sperrbeginn'), + $this->ReadPropertyInteger('Mindestsperrzeit'), + $jetzt + ) + : 0; + $wiederholsperreAktiv = $jetzt < $this->ReadAttributeInteger('WiederholsperreBis'); + $startenZulaessig = !$wiederholsperreAktiv + && !$sperrerholung + && $restMindestsperrzeit === 0; + $sperrenZulaessig = !$sperrerholung + && $restMindestlaufzeit === 0 + && !$max24hErreicht; + + $aktiv = (bool) $this->GetValue('Aktiv'); + $fehlerfrei = $this->ReadAttributeString('Schaltfehler') === ''; + $verfuegbar = $aktiv && $fehlerfrei; + $angebot = WaermepumpenRegler::leistungsangebot( + $this->ReadPropertyInteger('Nennleistung'), + $this->ReadAttributeString('Betriebsart'), + $laeuft, + $verfuegbar, + $restMindestlaufzeit > 0, + $restMindestsperrzeit > 0, + $sperrenZulaessig, + $startenZulaessig, + $sperrerholung + ); + $this->WriteAttributeString('Leistungsangebot', json_encode($angebot, JSON_THROW_ON_ERROR)); + if ((bool) $this->leseZustand('SollwertGueltig') + && !in_array((int) $this->leseZustand('Sollleistung'), $angebot, true) + ) { + $this->setzeZustand('Sollleistung', $angebot[0]); + } + + $zielzustand = $this->bestimmeZielzustand( + $aktiv, + $laeuft, + $restMindestlaufzeit, + $restMindestsperrzeit, + $sperrenZulaessig, + $sperrerholung + ); + if ($zielzustand !== $steuerzustand) { + $this->schalte($zielzustand, $jetzt); + $steuerzustand = $zielzustand; + } + $this->aktualisiereAnlauf($laeuft, $steuerzustand, $jetzt); + $steuerzustand = $this->ReadAttributeInteger('Steuerzustand'); + $sperrerholung = $this->ReadAttributeBoolean('Sperrerholung'); + $wiederholsperreAktiv = $jetzt < $this->ReadAttributeInteger('WiederholsperreBis'); + $startenZulaessig = !$wiederholsperreAktiv + && !$sperrerholung + && $restMindestsperrzeit === 0; + $sperrenZulaessig = !$sperrerholung + && $restMindestlaufzeit === 0 + && !$max24hErreicht; + $angebot = WaermepumpenRegler::leistungsangebot( + $this->ReadPropertyInteger('Nennleistung'), + $this->ReadAttributeString('Betriebsart'), + $laeuft, + $verfuegbar, + $restMindestlaufzeit > 0, + $restMindestsperrzeit > 0, + $sperrenZulaessig, + $startenZulaessig, + $sperrerholung + ); + $this->WriteAttributeString( + 'Leistungsangebot', + json_encode($angebot, JSON_THROW_ON_ERROR) + ); + + $aenderungMoeglich = $verfuegbar + && count($angebot) > 1 + && !$wiederholsperreAktiv + && !$sperrerholung; + $this->setzeZustand('Istleistung', $istleistung); + $this->setzeZustand( + 'Leistungsquelle', + $this->ReadPropertyInteger('Rueckmeldungsart') === WaermepumpenRegler::RUECKMELDUNG_LEISTUNG + ? Nachrichtenvertrag::LEISTUNGSQUELLE_GEMESSEN + : Nachrichtenvertrag::LEISTUNGSQUELLE_BERECHNET + ); + $this->setzeZustand('Verfuegbar', $verfuegbar); + $this->setzeZustand('AenderungMoeglich', $aenderungMoeglich); + $this->setzeZustand('AnlaufAusstehend', $this->ReadAttributeInteger('AnlaufDeadline') > $jetzt); + $this->setzeZustand('WiederholsperreAktiv', $wiederholsperreAktiv); + $this->setzeZustand('SperrerholungAktiv', $sperrerholung); + $this->setzeZustand('RestMindestlaufzeit', $restMindestlaufzeit); + $this->setzeZustand('RestMindestsperrzeit', $restMindestsperrzeit); + $this->setzeZustand('Sperrzeit24h', $sperrzeit24h); + $this->aktualisiereFachvariablen($laeuft, $steuerzustand); + $this->aktualisiereStoerung(); + $this->planeTimer($jetzt); + $this->SetStatus($fehlerfrei ? self::STATUS_AKTIV : self::STATUS_SCHALTFEHLER); + + if ($meldungPlanen) { + $this->planeMeldung(); + } + } catch (Throwable $fehler) { + $this->WriteAttributeString('Schaltfehler', $fehler->getMessage()); + $this->setzeZustand('Verfuegbar', false); + $this->setzeZustand('AenderungMoeglich', false); + $this->setzeStoerung($fehler->getMessage()); + $this->SetStatus(self::STATUS_SCHALTFEHLER); + $this->protokolliere('Aktualisierung', $fehler->getMessage()); + } + } + + private function bestimmeZielzustand( + bool $aktiv, + bool $laeuft, + int $restMindestlaufzeit, + int $restMindestsperrzeit, + bool $sperrenZulaessig, + bool $sperrerholung + ): int { + $aktuell = $this->ReadAttributeInteger('Steuerzustand'); + if (!$aktiv) { + return WaermepumpenRegler::STEUERZUSTAND_NORMAL; + } + if ($aktuell === WaermepumpenRegler::STEUERZUSTAND_SPERRE + && $restMindestsperrzeit > 0 + ) { + return WaermepumpenRegler::STEUERZUSTAND_SPERRE; + } + if ($sperrerholung) { + return WaermepumpenRegler::STEUERZUSTAND_NORMAL; + } + + $pv = $this->ReadAttributeString('Betriebsart') === Nachrichtenvertrag::BETRIEBSART_PV; + if ($laeuft && $restMindestlaufzeit > 0) { + return $pv + ? WaermepumpenRegler::STEUERZUSTAND_ERHOEHUNG + : WaermepumpenRegler::STEUERZUSTAND_NORMAL; + } + if (!(bool) $this->leseZustand('SollwertGueltig')) { + return WaermepumpenRegler::STEUERZUSTAND_NORMAL; + } + + $sollleistung = (int) $this->leseZustand('Sollleistung'); + if ($pv) { + return $sollleistung > 0 + ? WaermepumpenRegler::STEUERZUSTAND_ERHOEHUNG + : WaermepumpenRegler::STEUERZUSTAND_NORMAL; + } + + return $sollleistung === 0 && $sperrenZulaessig + ? WaermepumpenRegler::STEUERZUSTAND_SPERRE + : WaermepumpenRegler::STEUERZUSTAND_NORMAL; + } + + private function aktualisiereAnlauf(bool $laeuft, int $steuerzustand, int $jetzt): void + { + if ($laeuft) { + $this->WriteAttributeInteger('AnlaufDeadline', 0); + $this->WriteAttributeInteger('WiederholsperreBis', 0); + return; + } + + $pv = $this->ReadAttributeString('Betriebsart') === Nachrichtenvertrag::BETRIEBSART_PV; + $anlaufAngefordert = $pv + && $steuerzustand === WaermepumpenRegler::STEUERZUSTAND_ERHOEHUNG + && (bool) $this->leseZustand('SollwertGueltig') + && (int) $this->leseZustand('Sollleistung') === $this->ReadPropertyInteger('Nennleistung'); + if (!$anlaufAngefordert) { + $this->WriteAttributeInteger('AnlaufDeadline', 0); + return; + } + + $deadline = $this->ReadAttributeInteger('AnlaufDeadline'); + if ($deadline <= 0) { + $this->WriteAttributeInteger( + 'AnlaufDeadline', + $jetzt + $this->ReadPropertyInteger('Anlaufwartezeit') + ); + return; + } + if ($jetzt < $deadline) { + return; + } + + $this->schalte(WaermepumpenRegler::STEUERZUSTAND_NORMAL, $jetzt); + $this->WriteAttributeInteger('AnlaufDeadline', 0); + $this->WriteAttributeInteger( + 'WiederholsperreBis', + $jetzt + $this->ReadPropertyInteger('Wiederholsperre') + ); + $this->setzeZustand('Sollleistung', 0); + $this->setzeZustand('SollwertGueltig', true); + } + + private function aktualisiereLaufzustand(bool $laeuft, int $jetzt): void + { + $stand = $this->ReadAttributeInteger('LaufzeitStand'); + $vorher = $this->ReadAttributeBoolean('LetzterLaufstatus'); + if ($stand > 0 && $stand <= $jetzt && $vorher) { + $this->SetValue('Laufzeit', (int) $this->GetValue('Laufzeit') + $jetzt - $stand); + } + if ($laeuft !== $vorher) { + $this->WriteAttributeInteger('Laufbeginn', $laeuft ? $jetzt : 0); + if ($this->ReadAttributeBoolean('Sperrerholung')) { + $this->WriteAttributeInteger('ErholungsLaufbeginn', $laeuft ? $jetzt : 0); + } + } + $this->WriteAttributeBoolean('LetzterLaufstatus', $laeuft); + $this->WriteAttributeInteger('LaufzeitStand', $jetzt); + $this->SetValue('WaermepumpeLaeuft', $laeuft); + } + + private function restMindestlaufzeit(bool $laeuft, int $jetzt): int + { + if (!$laeuft) { + return 0; + } + $beginn = $this->ReadAttributeBoolean('Sperrerholung') + ? $this->ReadAttributeInteger('ErholungsLaufbeginn') + : $this->ReadAttributeInteger('Laufbeginn'); + + return WaermepumpenRegler::restzeit( + $beginn, + $this->ReadPropertyInteger('Mindestlaufzeit'), + $jetzt + ); + } + + private function aktualisiereSperrerholung(bool $laeuft, int $jetzt): void + { + if (!$this->ReadAttributeBoolean('Sperrerholung')) { + return; + } + $beginn = $this->ReadAttributeInteger('ErholungsLaufbeginn'); + if (!$laeuft) { + if ($beginn > 0) { + $this->WriteAttributeInteger('ErholungsLaufbeginn', 0); + } + return; + } + if ($beginn <= 0) { + $this->WriteAttributeInteger('ErholungsLaufbeginn', $jetzt); + return; + } + if (WaermepumpenRegler::restzeit( + $beginn, + $this->ReadPropertyInteger('Mindestlaufzeit'), + $jetzt + ) === 0) { + $this->WriteAttributeBoolean('Sperrerholung', false); + $this->WriteAttributeInteger('ErholungsLaufbeginn', 0); + } + } + + private function schalte(int $steuerzustand, int $jetzt): void + { + $werte = WaermepumpenRegler::kontaktwerte( + $this->ReadPropertyInteger('Kontaktart'), + $steuerzustand + ); + $this->WriteAttributeBoolean('SchaltvorgangLaeuft', true); + try { + $this->setzeKontakt(1, false); + $this->setzeKontakt(2, false); + if ($werte['Kontakt1']) { + $this->setzeKontakt(1, true); + } + if ($werte['Kontakt2']) { + $this->setzeKontakt(2, true); + } + } catch (Throwable $fehler) { + throw new RuntimeException('Kontakte konnten nicht gesetzt werden: ' . $fehler->getMessage()); + } finally { + $this->WriteAttributeBoolean('SchaltvorgangLaeuft', false); + } + + if ($this->leseLogischenKontakt(1) !== $werte['Kontakt1'] + || $this->leseLogischenKontakt(2) !== $werte['Kontakt2'] + ) { + throw new RuntimeException('Mindestens ein Kontakt hat den Schaltbefehl nicht uebernommen.'); + } + + $alt = $this->ReadAttributeInteger('Steuerzustand'); + if ($alt === WaermepumpenRegler::STEUERZUSTAND_SPERRE + && $steuerzustand !== WaermepumpenRegler::STEUERZUSTAND_SPERRE + ) { + $this->schliesseSperrintervall($jetzt); + } + if ($alt !== WaermepumpenRegler::STEUERZUSTAND_SPERRE + && $steuerzustand === WaermepumpenRegler::STEUERZUSTAND_SPERRE + ) { + $this->WriteAttributeInteger('Sperrbeginn', $jetzt); + } + $this->WriteAttributeInteger('Steuerzustand', $steuerzustand); + $this->WriteAttributeString('Schaltfehler', ''); + $this->protokolliere('Steuerzustand', ['Alt' => $alt, 'Neu' => $steuerzustand]); + } + + private function setzeKontakt(int $nummer, bool $logischerWert): void + { + $id = $this->ReadPropertyInteger('Kontakt' . $nummer . 'VariableID'); + $invertiert = $this->ReadPropertyBoolean('Kontakt' . $nummer . 'Invertiert'); + RequestAction($id, $logischerWert !== $invertiert); + } + + private function leseLogischenKontakt(int $nummer): bool + { + $id = $this->ReadPropertyInteger('Kontakt' . $nummer . 'VariableID'); + $invertiert = $this->ReadPropertyBoolean('Kontakt' . $nummer . 'Invertiert'); + + return (bool) GetValue($id) !== $invertiert; + } + + private function pruefeKontaktzustand(): void + { + $erwartet = WaermepumpenRegler::kontaktwerte( + $this->ReadPropertyInteger('Kontaktart'), + $this->ReadAttributeInteger('Steuerzustand') + ); + if ($this->leseLogischenKontakt(1) !== $erwartet['Kontakt1'] + || $this->leseLogischenKontakt(2) !== $erwartet['Kontakt2'] + ) { + $this->schalte($this->ReadAttributeInteger('Steuerzustand'), time()); + return; + } + $this->WriteAttributeString('Schaltfehler', ''); + } + + private function aktualisiereSperrhistorie(int $steuerzustand, int $jetzt): void + { + $historie = $this->leseSperrhistorie(); + $historie = WaermepumpenRegler::bereinigeSperrhistorie($historie, $jetzt); + $this->WriteAttributeString('Sperrhistorie', json_encode($historie, JSON_THROW_ON_ERROR)); + if ($steuerzustand === WaermepumpenRegler::STEUERZUSTAND_SPERRE + && $this->ReadAttributeInteger('Sperrbeginn') <= 0 + ) { + $this->WriteAttributeInteger('Sperrbeginn', $jetzt); + } + } + + private function schliesseSperrintervall(int $jetzt): void + { + $beginn = $this->ReadAttributeInteger('Sperrbeginn'); + if ($beginn > 0 && $beginn <= $jetzt) { + $historie = $this->leseSperrhistorie(); + $historie[] = ['Von' => $beginn, 'Bis' => $jetzt]; + $historie = WaermepumpenRegler::bereinigeSperrhistorie($historie, $jetzt); + $this->WriteAttributeString('Sperrhistorie', json_encode($historie, JSON_THROW_ON_ERROR)); + } + $this->WriteAttributeInteger('Sperrbeginn', 0); + } + + private function sperrsekunden24h(int $steuerzustand, int $jetzt): int + { + return WaermepumpenRegler::sperrsekunden24h( + $this->leseSperrhistorie(), + $jetzt, + $steuerzustand === WaermepumpenRegler::STEUERZUSTAND_SPERRE + ? $this->ReadAttributeInteger('Sperrbeginn') + : null + ); + } + + /** @return list */ + private function leseSperrhistorie(): array + { + $historie = json_decode( + $this->ReadAttributeString('Sperrhistorie'), + true, + 512, + JSON_THROW_ON_ERROR + ); + + return is_array($historie) ? array_values($historie) : []; + } + + private function aktualisiereFachvariablen(bool $laeuft, int $steuerzustand): void + { + $sperre = $steuerzustand === WaermepumpenRegler::STEUERZUSTAND_SPERRE; + $erhoehung = $steuerzustand === WaermepumpenRegler::STEUERZUSTAND_ERHOEHUNG; + $zustand = $sperre ? 4 : ($erhoehung ? 3 : ($laeuft ? 2 : 1)); + $this->SetValue('Waermepumpenzustand', $zustand); + $this->SetValue('SperreAktiv', $sperre); + $this->SetValue('ErhoehungAktiv', $erhoehung); + $this->SetValue( + 'SGReadyZustand', + ($this->leseLogischenKontakt(1) ? 1 : 0) + ($this->leseLogischenKontakt(2) ? 2 : 0) + ); + } + + private function initialisiereZustand(int $jetzt): void + { + if (!$this->ReadAttributeBoolean('Initialisiert')) { + $kontakt1 = $this->leseLogischenKontakt(1); + $kontakt2 = $this->leseLogischenKontakt(2); + if (!$kontakt1 && !$kontakt2) { + $zustand = WaermepumpenRegler::STEUERZUSTAND_NORMAL; + } elseif ($kontakt1 && !$kontakt2) { + $zustand = WaermepumpenRegler::STEUERZUSTAND_SPERRE; + } elseif ($kontakt2 && !$kontakt1) { + $zustand = WaermepumpenRegler::STEUERZUSTAND_ERHOEHUNG; + } else { + $zustand = WaermepumpenRegler::STEUERZUSTAND_NORMAL; + } + $this->WriteAttributeInteger('Steuerzustand', $zustand); + $this->WriteAttributeInteger( + 'Sperrbeginn', + $zustand === WaermepumpenRegler::STEUERZUSTAND_SPERRE ? $jetzt : 0 + ); + $laeuft = WaermepumpenRegler::laeuft( + $this->leseRueckmeldung(), + $this->ReadPropertyInteger('Rueckmeldungsart'), + $this->ReadPropertyFloat('Laufschwelle') + ); + $this->WriteAttributeBoolean('LetzterLaufstatus', $laeuft); + $this->WriteAttributeInteger('Laufbeginn', $laeuft ? $jetzt : 0); + $this->WriteAttributeInteger('LaufzeitStand', $jetzt); + $this->WriteAttributeBoolean('Initialisiert', true); + } + } + + /** @return bool|int|float */ + private function leseRueckmeldung() + { + return GetValue($this->ReadPropertyInteger('IstleistungVariableID')); + } + + 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'] as $property) { + if ($this->ReadPropertyInteger($property) <= 0) { + throw new InvalidArgumentException($property . ' muss groesser als 0 sein.'); + } + } + if ($this->ReadPropertyInteger('Nennleistung') <= 0) { + throw new InvalidArgumentException('Nennleistung muss groesser als 0 sein.'); + } + if ($this->ReadPropertyFloat('Laufschwelle') < 0) { + throw new InvalidArgumentException('Laufschwelle darf nicht negativ sein.'); + } + + $kontaktart = $this->ReadPropertyInteger('Kontaktart'); + WaermepumpenRegler::kontaktwerte( + $kontaktart, + WaermepumpenRegler::STEUERZUSTAND_NORMAL + ); + $rueckmeldungsart = $this->ReadPropertyInteger('Rueckmeldungsart'); + WaermepumpenRegler::laeuft(false, $rueckmeldungsart, 0.0); + + $kontakt1 = $this->ReadPropertyInteger('Kontakt1VariableID'); + $kontakt2 = $this->ReadPropertyInteger('Kontakt2VariableID'); + $this->pruefeVariable($kontakt1, 'Kontakt 1', [0], true); + $this->pruefeVariable($kontakt2, 'Kontakt 2', [0], true); + if ($kontakt1 === $kontakt2) { + throw new InvalidArgumentException('Kontakt 1 und Kontakt 2 muessen verschieden sein.'); + } + + $rueckmeldung = $this->ReadPropertyInteger('IstleistungVariableID'); + $typen = $rueckmeldungsart === WaermepumpenRegler::RUECKMELDUNG_BETRIEB + ? [0] + : [1, 2]; + $this->pruefeVariable($rueckmeldung, 'Rueckmeldung', $typen, false); + + foreach ([ + 'Anlaufwartezeit', + 'Wiederholsperre', + 'Mindestlaufzeit', + 'Mindestsperrzeit', + 'MaximaleSperrzeit', + 'MaximaleSperrzeit24h', + ] as $property) { + if ($this->ReadPropertyInteger($property) < 0) { + throw new InvalidArgumentException($property . ' darf nicht negativ sein.'); + } + } + if ($this->ReadPropertyInteger('MaximaleSperrzeit24h') > 86400) { + throw new InvalidArgumentException('MaximaleSperrzeit24h darf hoechstens 86400 Sekunden betragen.'); + } + } + + /** @param list $typen */ + private function pruefeVariable( + int $variableID, + string $bezeichnung, + array $typen, + bool $aktionErforderlich + ): void { + if ($variableID <= 0 || !IPS_VariableExists($variableID)) { + throw new InvalidArgumentException($bezeichnung . ' ist nicht eingerichtet.'); + } + $variable = IPS_GetVariable($variableID); + if (!in_array((int) $variable['VariableType'], $typen, true)) { + throw new InvalidArgumentException($bezeichnung . ' hat einen ungueltigen Variablentyp.'); + } + if ($aktionErforderlich + && (int) $variable['VariableAction'] <= 0 + && (int) $variable['VariableCustomAction'] <= 0 + ) { + throw new InvalidArgumentException( + $bezeichnung . ' benoetigt eine Standard- oder benutzerdefinierte Aktion.' + ); + } + } + + private function registriereMeldungen(): void + { + $definitionen = [ + 'Kontakt1' => $this->ReadPropertyInteger('Kontakt1VariableID'), + 'Kontakt2' => $this->ReadPropertyInteger('Kontakt2VariableID'), + 'Istleistung' => $this->ReadPropertyInteger('IstleistungVariableID'), + ]; + + foreach ($definitionen as $name => $neueID) { + $attribut = 'Registrierte' . $name . 'ID'; + $alteID = $this->ReadAttributeInteger($attribut); + if ($alteID > 0 && $alteID !== $neueID) { + $this->UnregisterMessage($alteID, self::VM_UPDATE); + } + if ($neueID > 0 && IPS_VariableExists($neueID)) { + $this->RegisterMessage($neueID, self::VM_UPDATE); + } + $this->WriteAttributeInteger($attribut, $neueID); + } + } + + private function pruefeVorgabeTimeout(int $jetzt): void + { + if (!(bool) $this->leseZustand('SollwertGueltig')) { + return; + } + $ablauf = $this->ReadAttributeInteger('LetzteVorgabeZeit') + + $this->ReadPropertyInteger('VorgabeTimeout'); + if ($this->ReadAttributeInteger('LetzteVorgabeZeit') <= 0 || $jetzt >= $ablauf) { + $this->setzeZustand('SollwertGueltig', false); + $this->SetTimerInterval('VorgabeTimeout', 0); + } + } + + private function planeVorgabeTimeout(int $jetzt): void + { + if (!(bool) $this->leseZustand('SollwertGueltig')) { + $this->SetTimerInterval('VorgabeTimeout', 0); + return; + } + + $ablauf = $this->ReadAttributeInteger('LetzteVorgabeZeit') + + $this->ReadPropertyInteger('VorgabeTimeout'); + $this->SetTimerInterval('VorgabeTimeout', max(1, ($ablauf - $jetzt) * 1000)); + } + + private function planeTimer(int $jetzt): void + { + $zeitpunkte = []; + foreach (['AnlaufDeadline', 'WiederholsperreBis'] as $attribut) { + $zeitpunkt = $this->ReadAttributeInteger($attribut); + if ($zeitpunkt > $jetzt) { + $zeitpunkte[] = $zeitpunkt; + } + } + $laufbeginn = $this->ReadAttributeInteger('Laufbeginn'); + if ($this->ReadAttributeBoolean('LetzterLaufstatus') && $laufbeginn > 0) { + $zeitpunkte[] = $laufbeginn + $this->ReadPropertyInteger('Mindestlaufzeit'); + } + if ($this->ReadAttributeInteger('Steuerzustand') + === WaermepumpenRegler::STEUERZUSTAND_SPERRE + ) { + $sperrbeginn = $this->ReadAttributeInteger('Sperrbeginn'); + if ($sperrbeginn > 0) { + $zeitpunkte[] = $sperrbeginn + $this->ReadPropertyInteger('Mindestsperrzeit'); + if ($this->ReadPropertyInteger('MaximaleSperrzeit') > 0) { + $zeitpunkte[] = $sperrbeginn + $this->ReadPropertyInteger('MaximaleSperrzeit'); + } + $maximum24h = $this->ReadPropertyInteger('MaximaleSperrzeit24h'); + if ($maximum24h > 0) { + $rest24h = $maximum24h - $this->sperrsekunden24h( + WaermepumpenRegler::STEUERZUSTAND_SPERRE, + $jetzt + ); + if ($rest24h > 0) { + $zeitpunkte[] = $jetzt + $rest24h; + } + } + } + } + + $zeitpunkte = array_values(array_filter( + $zeitpunkte, + static fn (int $zeitpunkt): bool => $zeitpunkt > $jetzt + )); + $this->SetTimerInterval( + 'Zustandspruefung', + $zeitpunkte === [] ? 0 : max(1, (min($zeitpunkte) - $jetzt) * 1000) + ); + $this->planeVorgabeTimeout($jetzt); + } + + private function planeMeldung(): void + { + $this->SetTimerInterval('MeldungVerzoegert', 100); + } + + private function aktualisiereStoerung(): void + { + $text = $this->ReadAttributeString('Schaltfehler'); + $this->setzeStoerung($text); + } + + private function setzeStoerung(string $text): void + { + $this->setzeZustand('Stoerung', $text !== ''); + $this->setzeZustand('Stoertext', $text); + } + + 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 + { + 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' => (int) $this->leseZustand('Leistungsquelle'), + 'Zustand' => $this->zustandsdaten(), + ]; + } + + /** @return list> */ + private function zustandsdaten(): array + { + return [ + [ + 'Kennung' => 'Sollleistung_W', + 'Art' => 'Sollwert', + 'Wert' => (bool) $this->leseZustand('SollwertGueltig') + ? (int) $this->leseZustand('Sollleistung') + : null, + 'Einheit' => 'W', + ], + [ + 'Kennung' => 'WaermepumpeLaeuft', + 'Art' => 'Status', + 'Wert' => (bool) $this->GetValue('WaermepumpeLaeuft'), + 'Einheit' => '', + ], + [ + 'Kennung' => 'Steuerzustand', + 'Art' => 'Status', + 'Wert' => $this->ReadAttributeInteger('Steuerzustand'), + 'Einheit' => '', + ], + [ + 'Kennung' => 'RestMindestlaufzeit_s', + 'Art' => 'Istwert', + 'Wert' => (int) $this->leseZustand('RestMindestlaufzeit'), + 'Einheit' => 's', + ], + + [ + 'Kennung' => 'RestMindestsperrzeit_s', + 'Art' => 'Istwert', + 'Wert' => (int) $this->leseZustand('RestMindestsperrzeit'), + 'Einheit' => 's', + ], + [ + 'Kennung' => 'Sperrzeit24h_s', + 'Art' => 'Istwert', + 'Wert' => (int) $this->leseZustand('Sperrzeit24h'), + 'Einheit' => 's', + ], + [ + 'Kennung' => 'SperrerholungAktiv', + 'Art' => 'Status', + 'Wert' => (bool) $this->leseZustand('SperrerholungAktiv'), + 'Einheit' => '', + ], + [ + 'Kennung' => 'Schaltfehler', + 'Art' => 'Stoerung', + 'Wert' => (bool) $this->leseZustand('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 : []; + } + + /** @return list */ + private function leseLeistungsangebot(): array + { + $angebot = json_decode( + $this->ReadAttributeString('Leistungsangebot'), + true, + 512, + JSON_THROW_ON_ERROR + ); + + return is_array($angebot) ? array_values(array_map('intval', $angebot)) : []; + } + + private function aktualisiereVariablen(): void + { + foreach (self::DIAGNOSE_VARIABLEN as $ident) { + $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->RegisterVariableBoolean('AnlaufAusstehend', 'Anlauf ausstehend', '~Switch', 200); + $this->RegisterVariableBoolean( + 'WiederholsperreAktiv', + 'Wiederholsperre aktiv', + '~Switch', + 210 + ); + $this->RegisterVariableBoolean( + 'SperrerholungAktiv', + 'Sperrerholung aktiv', + '~Switch', + 220 + ); + + $this->RegisterVariableInteger( + 'RestMindestlaufzeit', + 'Restliche Mindestlaufzeit', + '', + 230 + ); + $this->RegisterVariableInteger( + 'RestMindestsperrzeit', + 'Restliche Mindestsperrzeit', + '', + 240 + ); + $this->RegisterVariableInteger('Sperrzeit24h', 'Sperrzeit letzte 24 h', '', 250); + foreach (self::DIAGNOSE_VARIABLEN as $ident) { + $this->setzeZustand($ident, $this->leseZustand($ident)); + } + return; + } + + 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', + 'AnlaufAusstehend' => 'ZustandAnlaufAusstehend', + 'WiederholsperreAktiv' => 'ZustandWiederholsperreAktiv', + 'SperrerholungAktiv' => 'ZustandSperrerholungAktiv', + ]; + + $integerAttribute = [ + 'Leistungsquelle' => 'ZustandLeistungsquelle', + 'Sollleistung' => 'ZustandSollleistung', + 'RestMindestlaufzeit' => 'ZustandRestMindestlaufzeit', + 'RestMindestsperrzeit' => 'ZustandRestMindestsperrzeit', + 'Sperrzeit24h' => 'ZustandSperrzeit24h', + ]; + 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 ($ident === 'Istleistung') { + $wert = (float) $wert; + $this->WriteAttributeFloat('ZustandIstleistung', $wert); + } elseif ($ident === 'Stoertext') { + $wert = (string) $wert; + $this->WriteAttributeString('ZustandStoertext', $wert); + } else { + 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) + { + $attribute = [ + 'Istleistung' => ['Float', 'ZustandIstleistung'], + 'Leistungsquelle' => ['Integer', 'ZustandLeistungsquelle'], + 'Sollleistung' => ['Integer', 'ZustandSollleistung'], + + 'SollwertGueltig' => ['Boolean', 'ZustandSollwertGueltig'], + 'Verfuegbar' => ['Boolean', 'ZustandVerfuegbar'], + 'AenderungMoeglich' => ['Boolean', 'ZustandAenderungMoeglich'], + 'Stoerung' => ['Boolean', 'ZustandStoerung'], + 'Stoertext' => ['String', 'ZustandStoertext'], + 'AnlaufAusstehend' => ['Boolean', 'ZustandAnlaufAusstehend'], + 'WiederholsperreAktiv' => ['Boolean', 'ZustandWiederholsperreAktiv'], + 'SperrerholungAktiv' => ['Boolean', 'ZustandSperrerholungAktiv'], + 'RestMindestlaufzeit' => ['Integer', 'ZustandRestMindestlaufzeit'], + 'RestMindestsperrzeit' => ['Integer', 'ZustandRestMindestsperrzeit'], + 'Sperrzeit24h' => ['Integer', 'ZustandSperrzeit24h'], + ]; + if (!isset($attribute[$ident])) { + throw new LogicException('Unbekannter interner Zustand: ' . $ident); + } + + [$typ, $name] = $attribute[$ident]; + $methode = 'ReadAttribute' . $typ; + + return $this->{$methode}($name); + } + + private function deaktiviereTimer(): void + { + foreach ([ + 'Meldezyklus', + 'MeldungVerzoegert', + 'Zustandspruefung', + 'VorgabeTimeout', + ] 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/docs/module/README.md b/docs/module/README.md index ab68a9b..c85d55a 100644 --- a/docs/module/README.md +++ b/docs/module/README.md @@ -1,8 +1,8 @@ # EMS-Module und Modulentwürfe -> Manager, Warmwassererwaermer, Batterie, Verbraucher 1-Stufig, Ladestation Stand-Alone, -> Easee Gateway und Ladestation Gateway sind als installierbare IP-Symcon-Module -> umgesetzt. Die weiteren Ordner enthalten Besprechungsgrundlagen. +> Manager, Warmwassererwaermer, Batterie, Verbraucher 1-Stufig, Waermepumpe, +> Ladestation Stand-Alone, Easee Gateway und Ladestation Gateway 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 diff --git a/docs/module/Waermepumpe/README.md b/docs/module/Waermepumpe/README.md index 9d71658..eb50673 100644 --- a/docs/module/Waermepumpe/README.md +++ b/docs/module/Waermepumpe/README.md @@ -1,47 +1,89 @@ # Wärmepumpe -> Status: Diskussionsentwurf. +> Status: Implementiert. -Die Kontaktwirkung ist als Sperrkontakte oder SG Ready konfigurierbar. Die -elektrische Leistung kann fehlen, berechnet oder gemessen werden. +Das Modul bindet eine Wärmepumpe über zwei schaltbare Boolean-Ausgänge in den +Enelix-Manager ein. Die Ausgänge können als getrennte Kontakte für Sperre und +Erhöhung oder als SG-Ready-Eingänge verwendet werden. Die Regelung arbeitet mit +dem Nachrichtenvertrag 4.0 und meldet für PV und Peak ein +zustandsabhängiges Leistungsangebot. -## Zusätzliche Variablen +## Verhalten -| Ident | Typ / Zugriff | Beschreibung | +- Im PV-Betrieb kann eine freigegebene Wärmepumpe aus dem Normalzustand in die + Erhöhung geschaltet werden. +- Im Peak-Betrieb kann eine laufende Wärmepumpe gesperrt werden, sofern + Mindestlaufzeit und Sperrgrenzen dies erlauben. +- Schaltvorgänge erfolgen als Break-before-Make: beide Kontakte werden zuerst + deaktiviert, danach wird höchstens ein Kontakt aktiviert. +- Nach einem erfolglosen PV-Anlauf wird die Erhöhung aufgehoben und bis zum + Ablauf der Wiederholsperre nicht erneut angeboten. +- Die maximale Sperrzeit am Stück und innerhalb von 24 Stunden erzwingt eine + Erholungsphase im Normalbetrieb. +- Ohne gültige Manager-Vorgabe fällt die Steuerung in den sicheren + Normalzustand zurück. + +## Konfiguration + +| Property | Standard | Beschreibung | +| --- | ---: | --- | +| `Kontaktart` | `0` | `0`: Sperre/Erhöhung, `1`: SG Ready | +| `Kontakt1VariableID` | `0` | Schaltbarer Ausgang für Sperre beziehungsweise SG Ready 1 | +| `Kontakt2VariableID` | `0` | Schaltbarer Ausgang für Erhöhung beziehungsweise SG Ready 2 | +| `Kontakt1Invertiert` | `false` | Logik des ersten Ausgangs invertieren | +| `Kontakt2Invertiert` | `false` | Logik des zweiten Ausgangs invertieren | +| `Nennleistung` | `0 W` | Elektrische Nennleistung der Wärmepumpe | +| `Rueckmeldungsart` | `0` | `0`: gemessene Leistung, `1`: Boolean-Betriebsstatus | +| `IstleistungVariableID` | `0` | Leistungsmessung oder Betriebsrückmeldung | +| `Laufschwelle` | `100 W` | Ab dieser Leistung gilt die Wärmepumpe als laufend | +| `Anlaufwartezeit` | `30 s` | Wartezeit auf bestätigten Anlauf | +| `Wiederholsperre` | `300 s` | Pause nach einem erfolglosen Anlauf | +| `Mindestlaufzeit` | `1200 s` | Mindestlaufzeit nach bestätigtem Start | +| `Mindestsperrzeit` | `1200 s` | Mindestdauer einer begonnenen Sperre | +| `MaximaleSperrzeit` | `7200 s` | Maximale zusammenhängende Sperre; `0` deaktiviert | +| `MaximaleSperrzeit24h` | `21600 s` | Maximale Sperrsumme in 24 Stunden; `0` deaktiviert | + +Hinzu kommen die gemeinsamen Verbraucher-Properties für Prioritäten, +Meldeintervall, Vorgabe-Timeout, Visualisierung und Logging. + +## Variablen + +| Ident | Typ | Beschreibung | | --- | --- | --- | -| `Waermepumpenzustand` | Integer / Anzeige | `0` unbekannt, `1` aus, `2` normal, `3` erhöht, `4` gesperrt. | -| `SperreAktiv` | Boolean / Anzeige | Aktuelle Sperranforderung. | -| `ErhoehungAktiv` | Boolean / Anzeige | Aktuelle Erhöhungsanforderung. | -| `SGReadyZustand` | Integer / Anzeige | Bei SG Ready Zustand 0–3. | -| `Laufzeit` | Integer / Anzeige | Kumulierte bestätigte Laufzeit in Sekunden. | +| `Waermepumpenzustand` | Integer | `1` normal/aus, `2` normal/laufend, `3` erhöht, `4` gesperrt | +| `WaermepumpeLaeuft` | Boolean | Bestätigter Betriebsstatus | +| `SperreAktiv` | Boolean | Sperrkontakt ist aktiv | +| `ErhoehungAktiv` | Boolean | Erhöhungskontakt ist aktiv | +| `SGReadyZustand` | Integer | Bitwert der beiden logischen Kontakte | +| `Laufzeit` | Integer | Kumulierte bestätigte Laufzeit in Sekunden | -## Zusätzliche Properties +Optionale Diagnosevariablen zeigen unter anderem Ist- und Sollleistung, +Verfügbarkeit, Anlaufstatus, Wiederholsperre, Sperrerholung, verbleibende +Mindestzeiten und die Sperrsumme der letzten 24 Stunden. -| Ident | Typ | Standard / Beschreibung | -| --- | --- | --- | -| `Kontaktwirkung` | Auswahl | `Sperrkontakte`; alternativ `SG Ready`. | -| `Kontakt1VariableID` | Integer | `0`; Sperre oder SG-Ready-Eingang 1. | -| `Kontakt2VariableID` | Integer | `0`; Erhöhung oder SG-Ready-Eingang 2. | -| `Kontakt1Invertiert` | Boolean | `false`. | -| `Kontakt2Invertiert` | Boolean | `false`. | -| `Leistungsermittlung` | Auswahl | `Nicht vorhanden`, `Berechnet` oder `Gemessen`; entspricht Quelle 0/1/2. | -| `LeistungsmessungVariableID` | Integer | `0`; nur bei gemessener Leistung. | -| `Zustandskennlinie` | String/JSON | `[]`; bei berechneter Leistung je Zustand Planleistung und Kontaktbelegung. | -| `BetriebsrueckmeldungVariableID` | Integer | `0`; optionale echte Betriebsrückmeldung. | -| `Mindestlaufzeit` | Integer | `0*` s; anlagengerecht festlegen. | -| `Mindeststillstand` | Integer | `0*` s; anlagengerecht festlegen. | -| `MaxSperrzeit` | Integer | `0` s; `0` deaktiviert die EMS-Sperrfunktion. | +## Adaption aus Enelix 1 -Zeit- und wetterabhängiges Sperren beziehungsweise Erhöhen aus dem Altmodul -entfällt. +| Bereich | Behandlung in Enelix 2 | +| --- | --- | +| Grundzustände Normal, Sperre und Erhöhung | angepasst übernommen | +| Zwei physische Kontakte | angepasst und mit Invertierung sowie Rücklesekontrolle umgesetzt | +| Leistungsangebot an den Manager | neu nach Nachrichtenvertrag 4.0 implementiert | +| Mindestlauf- und Sperrzeiten | neu als ereignisbasierte Schutzlogik implementiert | +| Wetter-, Wolken- und Sonnenaufgangslogik | nicht uebernommen | +| Warmwasser-Schwellwert im Wärmepumpenmodul | nicht uebernommen | +| Fester zyklischer Fünf-Sekunden-Regler | nicht uebernommen; Ereignisse und Einmal-Timer | -## Zustand +## Lizenzierung und Manager -`Waermepumpenzustand`, `SperreAktiv`, `ErhoehungAktiv` beziehungsweise -`SGReadyZustand` als Status, `Laufzeit_s` als Istwert und -`Kommunikationsfehler` als Störung. +Der Manager erkennt das Modul über die Modul-ID +`{A31C9274-54F7-4BD2-9804-7AF23E86C4D1}`. Es verwendet das bestehende +Lizenzkontingent `consumer_single`, das im Portal als einstufiger Verbraucher +mit Wärmepumpe als Beispiel angeboten wird. -## Offene Punkte +## Sicherheitshinweise -- Der Formularbutton „Wassererwärmer über Wärmepumpe betreiben“ bleibt ohne - Regelwirkung, bis Anschluss und Verriegelung gemeinsam definiert sind. +Die beiden Ausgänge müssen unterschiedliche, schaltbare Boolean-Variablen sein. +Die Rückmeldung ist verpflichtend: entweder eine numerische Leistungsmessung +oder ein Boolean-Betriebsstatus. Bei ungültiger Konfiguration, fehlgeschlagenem +Schalten oder widersprüchlicher Rücklesung setzt das Modul seinen Status auf +Fehler und meldet sich dem Manager als nicht verfügbar. diff --git a/libs/WaermepumpenRegler.php b/libs/WaermepumpenRegler.php new file mode 100644 index 0000000..7dedda7 --- /dev/null +++ b/libs/WaermepumpenRegler.php @@ -0,0 +1,190 @@ + $steuerzustand === self::STEUERZUSTAND_SPERRE, + 'Kontakt2' => $steuerzustand === self::STEUERZUSTAND_ERHOEHUNG, + ]; + } + + /** @param bool|int|float $rueckmeldung */ + public static function laeuft($rueckmeldung, int $rueckmeldungsart, float $laufschwelle): bool + { + self::pruefeRueckmeldungsart($rueckmeldungsart); + if ($laufschwelle < 0) { + throw new InvalidArgumentException('Die Laufschwelle darf nicht negativ sein.'); + } + + if ($rueckmeldungsart === self::RUECKMELDUNG_BETRIEB) { + return (bool) $rueckmeldung; + } + + return abs((float) $rueckmeldung) >= $laufschwelle; + } + + /** @param bool|int|float $rueckmeldung */ + public static function istleistung( + $rueckmeldung, + int $rueckmeldungsart, + int $nennleistung + ): float { + self::pruefeRueckmeldungsart($rueckmeldungsart); + self::pruefeNennleistung($nennleistung); + + if ($rueckmeldungsart === self::RUECKMELDUNG_BETRIEB) { + return (bool) $rueckmeldung ? (float) $nennleistung : 0.0; + } + + return max(0.0, (float) $rueckmeldung); + } + + /** @return list */ + public static function leistungsangebot( + int $nennleistung, + string $betriebsart, + bool $laeuft, + bool $verfuegbar, + bool $mindestlaufzeitAktiv, + bool $mindestsperrzeitAktiv, + bool $sperrenZulaessig, + bool $startenZulaessig, + bool $sperrerholung + ): array { + self::pruefeNennleistung($nennleistung); + if (!in_array($betriebsart, [ + Nachrichtenvertrag::BETRIEBSART_PV, + Nachrichtenvertrag::BETRIEBSART_PEAK, + ], true)) { + throw new InvalidArgumentException('Die Betriebsart ist ungueltig.'); + } + + if (!$verfuegbar) { + return [$laeuft ? $nennleistung : 0]; + } + if ($sperrerholung || $mindestlaufzeitAktiv) { + return [$laeuft ? $nennleistung : 0]; + } + + if ($betriebsart === Nachrichtenvertrag::BETRIEBSART_PV) { + return ($laeuft || (!$mindestsperrzeitAktiv && $startenZulaessig)) + ? [0, $nennleistung] + : [0]; + } + + return ($laeuft && $sperrenZulaessig) + ? [0, $nennleistung] + : [$laeuft ? $nennleistung : 0]; + } + + public static function restzeit(int $beginn, int $dauer, int $jetzt): int + { + if ($beginn < 0 || $dauer < 0 || $jetzt < 0) { + throw new InvalidArgumentException('Zeitwerte duerfen nicht negativ sein.'); + } + if ($beginn === 0 || $dauer === 0) { + return 0; + } + + return max(0, $beginn + $dauer - $jetzt); + } + + /** + * @param list $historie + * @return list + */ + public static function bereinigeSperrhistorie(array $historie, int $jetzt): array + { + if ($jetzt < 0) { + throw new InvalidArgumentException('Der Zeitpunkt darf nicht negativ sein.'); + } + + $grenze = $jetzt - 86400; + $ergebnis = []; + foreach ($historie as $intervall) { + $von = (int) ($intervall['Von'] ?? -1); + $bis = (int) ($intervall['Bis'] ?? -1); + if ($von < 0 || $bis < $von || $bis <= $grenze) { + continue; + } + $ergebnis[] = ['Von' => max($von, $grenze), 'Bis' => min($bis, $jetzt)]; + } + + return $ergebnis; + } + + /** @param list $historie */ + public static function sperrsekunden24h( + array $historie, + int $jetzt, + ?int $aktiverBeginn = null + ): int { + $sekunden = 0; + foreach (self::bereinigeSperrhistorie($historie, $jetzt) as $intervall) { + $sekunden += $intervall['Bis'] - $intervall['Von']; + } + if ($aktiverBeginn !== null && $aktiverBeginn > 0) { + $sekunden += max(0, $jetzt - max($aktiverBeginn, $jetzt - 86400)); + } + + return min(86400, $sekunden); + } + + private static function pruefeKontaktart(int $kontaktart): void + { + if (!in_array($kontaktart, [ + self::KONTAKTART_SPERRE_ERHOEHUNG, + self::KONTAKTART_SG_READY, + ], true)) { + throw new InvalidArgumentException('Die Kontaktart ist ungueltig.'); + } + } + + private static function pruefeRueckmeldungsart(int $rueckmeldungsart): void + { + if (!in_array($rueckmeldungsart, [ + self::RUECKMELDUNG_LEISTUNG, + self::RUECKMELDUNG_BETRIEB, + ], true)) { + throw new InvalidArgumentException('Die Rueckmeldungsart ist ungueltig.'); + } + } + + private static function pruefeSteuerzustand(int $steuerzustand): void + { + if (!in_array($steuerzustand, [ + self::STEUERZUSTAND_NORMAL, + self::STEUERZUSTAND_SPERRE, + self::STEUERZUSTAND_ERHOEHUNG, + ], true)) { + throw new InvalidArgumentException('Der Steuerzustand ist ungueltig.'); + } + } + + private static function pruefeNennleistung(int $nennleistung): void + { + if ($nennleistung <= 0) { + throw new InvalidArgumentException('Die Nennleistung muss groesser als 0 sein.'); + } + } +} diff --git a/tests/DokumentationsstrukturTest.php b/tests/DokumentationsstrukturTest.php index 21a26b5..1ab0bf6 100644 --- a/tests/DokumentationsstrukturTest.php +++ b/tests/DokumentationsstrukturTest.php @@ -8,24 +8,16 @@ use PHPUnit\Framework\TestCase; final class DokumentationsstrukturTest extends TestCase { - /** @return list */ - public function modulReadmes(): array + public function testWaermepumpeDokumentiertDenImplementiertenStand(): void { - return [ - ['Waermepumpe'], - ]; - } - - /** @dataProvider modulReadmes */ - public function testJederModulentwurfHatEinReadme(string $modul): void - { - $pfad = __DIR__ . '/../docs/module/' . $modul . '/README.md'; + $pfad = __DIR__ . '/../docs/module/Waermepumpe/README.md'; self::assertFileExists($pfad); $inhalt = file_get_contents($pfad); self::assertNotFalse($inhalt); - self::assertStringContainsString('Status: Diskussionsentwurf', $inhalt); - self::assertStringContainsString('Properties', $inhalt); + self::assertStringContainsString('Status: Implementiert', $inhalt); + self::assertStringContainsString('Konfiguration', $inhalt); + self::assertStringContainsString('Adaption aus Enelix 1', $inhalt); } public function testBatterieDokumentiertDenImplementiertenStand(): void diff --git a/tests/Symcon/manifest.php b/tests/Symcon/manifest.php index fee8dff..ea739a2 100644 --- a/tests/Symcon/manifest.php +++ b/tests/Symcon/manifest.php @@ -74,6 +74,16 @@ return [ 'libs/Nachrichtenvertrag.php', ], ], + 'Waermepumpe' => [ + 'file' => __DIR__ . '/modules/Waermepumpe.php', + 'paths' => [ + 'Waermepumpe/*', + 'libs/WaermepumpenRegler.php', + 'libs/VerbraucherBasisTrait.php', + 'libs/VerbraucherSchnittstelle.php', + 'libs/Nachrichtenvertrag.php', + ], + ], 'Warmwassererwaermer' => [ 'file' => __DIR__ . '/modules/Warmwassererwaermer.php', 'paths' => [ diff --git a/tests/Symcon/modules/Waermepumpe.php b/tests/Symcon/modules/Waermepumpe.php new file mode 100644 index 0000000..c33fa72 --- /dev/null +++ b/tests/Symcon/modules/Waermepumpe.php @@ -0,0 +1,140 @@ +createActionVariable('WP Kontakt 1'); + $kontakt2 = $test->createActionVariable('WP Kontakt 2'); + $rueckmeldung = $test->createVariable(0, 'WP läuft', false); + $waermepumpe = $test->createInstance( + '{A31C9274-54F7-4BD2-9804-7AF23E86C4D1}', + 'Wärmepumpe', + [ + 'Kontaktart' => 0, + 'Kontakt1VariableID' => $kontakt1, + 'Kontakt2VariableID' => $kontakt2, + 'Nennleistung' => 5000, + 'Rueckmeldungsart' => 1, + 'IstleistungVariableID' => $rueckmeldung, + 'Anlaufwartezeit' => 1, + 'Wiederholsperre' => 3, + 'Mindestlaufzeit' => 1, + 'Mindestsperrzeit' => 0, + 'MaximaleSperrzeit' => 2, + 'MaximaleSperrzeit24h' => 60, + 'VorgabeTimeout' => 30, + 'DiagnosevariablenAnzeigen' => true, + ] + ); + + $manager = $test->createInstance( + '{6F771B18-59D4-4C8A-B951-3B2FE9F6A2C4}', + 'WP Hilfsmanager', + [ + 'AutomatischeSuche' => false, + 'VerbraucherZuordnung' => json_encode([ + ['InstanzID' => $waermepumpe, 'Aktiv' => true], + ], JSON_THROW_ON_ERROR), + ] + ); + + $senden = static function (?int $leistung, string $betriebsart) use ( + $manager, + $waermepumpe + ): void { + IPS_RequestAction( + $waermepumpe, + 'ManagerdatenEmpfangen', + json_encode([ + 'Kopf' => [ + 'Version' => '4.0', + 'AbsenderID' => $manager, + 'EmpfaengerID' => $waermepumpe, + 'Zeitpunkt' => time(), + ], + 'Betriebsart' => $betriebsart, + 'Sollleistung_W' => $leistung, + ], JSON_THROW_ON_ERROR) + ); + }; + + $test->runCase('Instanz und Diagnosevariablen werden angelegt', static function ( + TestContext $test + ) use ($waermepumpe): void { + $test->assertInstanceStatus($waermepumpe); + foreach ([ + 'Aktiv', + 'Waermepumpenzustand', + 'WaermepumpeLaeuft', + 'SperreAktiv', + 'ErhoehungAktiv', + 'SGReadyZustand', + 'Istleistung', + 'Sollleistung', + 'AnlaufAusstehend', + 'Sperrzeit24h', + ] as $ident) { + $test->objectByIdent($ident, $waermepumpe); + } + }); + + $test->runCase('PV fordert Erhoehung an und bestaetigt den Anlauf', static function ( + TestContext $test + ) use ($waermepumpe, $kontakt1, $kontakt2, $rueckmeldung, $senden): void { + IPS_RequestAction($waermepumpe, 'Aktiv', true); + $senden(null, 'PV'); + $senden(5000, 'PV'); + $test->waitUntil(static fn (): bool => GetValue($kontakt2) === true); + $test->assertSame(false, GetValue($kontakt1), 'Kontakt 1 muss im PV-Anlauf aus sein.'); + $test->assertSame(true, GetValue( + $test->objectByIdent('AnlaufAusstehend', $waermepumpe) + ), 'Der PV-Anlauf muss bis zur Rueckmeldung ausstehend sein.'); + + $test->waitUntil(static fn (): bool => GetValue($kontakt2) === false, 2500); + $test->waitUntil(static fn (): bool => GetValue( + $test->objectByIdent('WiederholsperreAktiv', $waermepumpe) + ) === true); + $test->assertSame(true, GetValue( + $test->objectByIdent('WiederholsperreAktiv', $waermepumpe) + ), 'Nach dem fehlgeschlagenen Anlauf muss die Wiederholsperre aktiv sein.'); + $test->assertSame(false, GetValue( + $test->objectByIdent('AenderungMoeglich', $waermepumpe) + ), 'Waehrend der Wiederholsperre darf keine Aenderung angeboten werden.'); + $test->waitUntil(static fn (): bool => GetValue( + $test->objectByIdent('WiederholsperreAktiv', $waermepumpe) + ) === false, 4500); + + $senden(5000, 'PV'); + $test->waitUntil(static fn (): bool => GetValue($kontakt2) === true); + SetValue($rueckmeldung, true); + $test->waitUntil(static fn (): bool => GetValue( + $test->objectByIdent('WaermepumpeLaeuft', $waermepumpe) + ) === true); + $test->assertSame(5000, (int) GetValue( + $test->objectByIdent('Istleistung', $waermepumpe) + )); + IPS_Sleep(1100); + }); + + $test->runCase('Peak sperrt und maximale Sperrzeit erzwingt Normalbetrieb', static function ( + TestContext $test + ) use ($waermepumpe, $kontakt1, $kontakt2, $senden): void { + $senden(null, 'Peak'); + $senden(0, 'Peak'); + $test->waitUntil(static fn (): bool => GetValue($kontakt1) === true); + $test->assertSame(false, GetValue($kontakt2)); + $test->assertSame(true, GetValue( + $test->objectByIdent('SperreAktiv', $waermepumpe) + )); + + $test->waitUntil(static fn (): bool => GetValue($kontakt1) === false, 2500); + $test->assertSame(true, GetValue( + $test->objectByIdent('SperrerholungAktiv', $waermepumpe) + )); + $test->assertSame(false, GetValue( + $test->objectByIdent('SperreAktiv', $waermepumpe) + )); + }); +}; diff --git a/tests/SymconTestContractTest.php b/tests/SymconTestContractTest.php index abf8021..506d740 100644 --- a/tests/SymconTestContractTest.php +++ b/tests/SymconTestContractTest.php @@ -65,6 +65,7 @@ final class SymconTestContractTest extends TestCase 'Manager', 'Pufferspeicher', 'VerbraucherEinStufig', + 'Waermepumpe', 'Warmwassererwaermer', ], $this->resolveAffectedModules(['libs/Nachrichtenvertrag.php']) @@ -90,6 +91,7 @@ final class SymconTestContractTest extends TestCase 'Manager', 'Pufferspeicher', 'VerbraucherEinStufig', + 'Waermepumpe', 'Warmwassererwaermer', ], $this->resolveAffectedModules(['tests/Symcon/TestContext.php']) diff --git a/tests/WaermepumpeModulstrukturTest.php b/tests/WaermepumpeModulstrukturTest.php new file mode 100644 index 0000000..7e609d2 --- /dev/null +++ b/tests/WaermepumpeModulstrukturTest.php @@ -0,0 +1,136 @@ +setzeKontakt(1, false)'); + $ein = strpos($modul, '$this->setzeKontakt(1, true)'); + + self::assertIsInt($aus); + self::assertIsInt($ein); + self::assertLessThan($ein, $aus); + } + + public function testFachUndDiagnosevariablenSindVorhanden(): void + { + $modul = (string) file_get_contents(self::MODULPFAD . 'module.php'); + foreach ([ + 'Waermepumpenzustand', + 'WaermepumpeLaeuft', + 'SperreAktiv', + 'ErhoehungAktiv', + 'SGReadyZustand', + 'Laufzeit', + 'AnlaufAusstehend', + 'WiederholsperreAktiv', + 'SperrerholungAktiv', + 'Sperrzeit24h', + ] as $ident) { + self::assertStringContainsString("'" . $ident . "'", $modul); + } + self::assertStringContainsString("ReadPropertyBoolean('DiagnosevariablenAnzeigen')", $modul); + self::assertStringContainsString('UnregisterVariable($ident)', $modul); + } + + public function testManagerKenntDieModulIDGenauEinmal(): void + { + $manager = (string) file_get_contents(__DIR__ . '/../Manager/module.php'); + + self::assertSame(1, substr_count($manager, self::MODULID)); + } + + public function testDokumentationBeschreibtImplementiertenStand(): void + { + $dokumentation = (string) file_get_contents( + __DIR__ . '/../docs/module/Waermepumpe/README.md' + ); + + self::assertStringContainsString('Status: Implementiert', $dokumentation); + self::assertStringContainsString('nicht uebernommen', $dokumentation); + } +} diff --git a/tests/WaermepumpenReglerTest.php b/tests/WaermepumpenReglerTest.php new file mode 100644 index 0000000..8abf728 --- /dev/null +++ b/tests/WaermepumpenReglerTest.php @@ -0,0 +1,162 @@ + false, 'Kontakt2' => false], + WaermepumpenRegler::kontaktwerte(0, WaermepumpenRegler::STEUERZUSTAND_NORMAL) + ); + self::assertSame( + ['Kontakt1' => true, 'Kontakt2' => false], + WaermepumpenRegler::kontaktwerte(1, WaermepumpenRegler::STEUERZUSTAND_SPERRE) + ); + self::assertSame( + ['Kontakt1' => false, 'Kontakt2' => true], + WaermepumpenRegler::kontaktwerte(1, WaermepumpenRegler::STEUERZUSTAND_ERHOEHUNG) + ); + } + + public function testLeistungUndBetriebsstatusBestimmenLaufUndIstleistung(): void + { + self::assertTrue(WaermepumpenRegler::laeuft(650.0, 0, 100.0)); + self::assertFalse(WaermepumpenRegler::laeuft(50.0, 0, 100.0)); + self::assertTrue(WaermepumpenRegler::laeuft(true, 1, 100.0)); + self::assertSame(650.0, WaermepumpenRegler::istleistung(650.0, 0, 5000)); + self::assertSame(5000.0, WaermepumpenRegler::istleistung(true, 1, 5000)); + } + + public function testPvBietetStartUndErhoehungNurBeiFreigabeAn(): void + { + self::assertSame( + [0, 5000], + WaermepumpenRegler::leistungsangebot( + 5000, + Nachrichtenvertrag::BETRIEBSART_PV, + false, + true, + false, + false, + true, + true, + false + ) + ); + self::assertSame( + [0], + WaermepumpenRegler::leistungsangebot( + 5000, + Nachrichtenvertrag::BETRIEBSART_PV, + false, + true, + false, + true, + true, + false, + false + ) + ); + } + + public function testPeakBietetSperreNurBeiLaufenderUndFreigegebenerPumpeAn(): void + { + self::assertSame( + [0, 5000], + WaermepumpenRegler::leistungsangebot( + 5000, + Nachrichtenvertrag::BETRIEBSART_PEAK, + true, + true, + false, + false, + true, + true, + false + ) + ); + self::assertSame( + [5000], + WaermepumpenRegler::leistungsangebot( + 5000, + Nachrichtenvertrag::BETRIEBSART_PEAK, + true, + true, + true, + false, + false, + true, + false + ) + ); + self::assertSame( + [0], + WaermepumpenRegler::leistungsangebot( + 5000, + Nachrichtenvertrag::BETRIEBSART_PEAK, + false, + true, + false, + false, + true, + true, + false + ) + ); + } + + public function testSperrerholungHaeltDenBestaetigtenBetrieb(): void + { + self::assertSame( + [5000], + WaermepumpenRegler::leistungsangebot( + 5000, + Nachrichtenvertrag::BETRIEBSART_PEAK, + true, + true, + false, + false, + true, + true, + true + ) + ); + } + + public function testRestzeitWirdNieNegativ(): void + { + self::assertSame(20, WaermepumpenRegler::restzeit(100, 30, 110)); + self::assertSame(0, WaermepumpenRegler::restzeit(100, 30, 140)); + self::assertSame(0, WaermepumpenRegler::restzeit(0, 30, 10)); + } + + public function testSperrzeit24hSchneidetAlteIntervalleAb(): void + { + $jetzt = 200000; + $historie = [ + ['Von' => $jetzt - 90000, 'Bis' => $jetzt - 80000], + ['Von' => $jetzt - 1000, 'Bis' => $jetzt - 400], + ]; + + self::assertSame( + 7400, + WaermepumpenRegler::sperrsekunden24h($historie, $jetzt, $jetzt - 400) + ); + } + + public function testUngueltigeKontaktartWirdAbgewiesen(): void + { + $this->expectException(InvalidArgumentException::class); + + WaermepumpenRegler::kontaktwerte(99, WaermepumpenRegler::STEUERZUSTAND_NORMAL); + } +}