Verbraucher Warmwassererwaermer implementieren
Tests / test (push) Canceled after 0s

This commit is contained in:
dh
2026-09-16 05:18:01 +00:00
parent 0a3ece3a81
commit 7a3c436039
14 changed files with 1751 additions and 69 deletions
+5 -3
View File
@@ -5,14 +5,14 @@ Energiemanagement, Manager und steuerbare Verbraucher fuer IP-Symcon.
## Status ## Status
Das Repository befindet sich im Aufbau. Der gemeinsame Nachrichtenvertrag, die Das Repository befindet sich im Aufbau. Der gemeinsame Nachrichtenvertrag, die
Deklaration der Verbraucherbasis und das Manager-Modul sind implementiert. Die Verbraucherbasis, der Manager und der mehrstufige Warmwassererwaermer sind
weiteren Module sind als Diskussionsentwürfe dokumentiert. implementiert. Die weiteren Module sind als Diskussionsentwürfe dokumentiert.
## Geplante Module ## Geplante Module
- Manager (implementiert) - Manager (implementiert)
- Batterie - Batterie
- Wassererwaermer - Wassererwaermer (implementiert)
- Pufferspeicher - Pufferspeicher
- Verbraucher 1-Stufig - Verbraucher 1-Stufig
- Waermepumpe - Waermepumpe
@@ -50,5 +50,7 @@ Neue oder geaenderte Funktionen werden zusammen mit ihren PHPUnit-Tests eingeche
- [Manager-Verbraucher-Schnittstelle](docs/Schnittstelle.md) - [Manager-Verbraucher-Schnittstelle](docs/Schnittstelle.md)
- [Manager-Modul](docs/module/Manager/README.md) - [Manager-Modul](docs/module/Manager/README.md)
- [Warmwassererwaermer-Modul](docs/module/Wassererwaermer/README.md)
- [Migration Boiler x-Stufig](docs/migration/Boiler-x-Stufig.md)
- [Obere Anschlüsse des Managers](docs/Obere-Anschluesse.md) - [Obere Anschlüsse des Managers](docs/Obere-Anschluesse.md)
- [Modulübersicht](docs/module/README.md) - [Modulübersicht](docs/module/README.md)
+288
View File
@@ -0,0 +1,288 @@
{
"elements": [
{
"type": "Label",
"caption": "Mehrstufiger Warmwassererwaermer nach Enelix-2-Vertrag 3.0"
},
{
"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": "Interval",
"caption": "Regelintervall",
"suffix": " s",
"minimum": 1
},
{
"type": "NumberSpinner",
"name": "IdleCounterMax",
"caption": "Zyklen bis Idle",
"minimum": 1
}
]
},
{
"type": "ExpansionPanel",
"caption": "Leistungsstufen",
"expanded": true,
"items": [
{
"type": "List",
"name": "LeistungsStufen",
"caption": "Exklusive Leistungsstufen und Schaltkontakte",
"add": true,
"delete": true,
"sortable": 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": "auto",
"add": 0,
"edit": {
"type": "SelectVariable"
}
}
]
}
]
},
{
"type": "ExpansionPanel",
"caption": "Temperatur und Speicher",
"expanded": true,
"items": [
{
"type": "SelectVariable",
"name": "Boilerfuehler_PT1",
"caption": "Boilerfuehler"
},
{
"type": "NumberSpinner",
"name": "TemperaturMaxAlter",
"caption": "Maximales Messwertalter",
"suffix": " s",
"minimum": 1
},
{
"type": "CheckBox",
"name": "BoilertemperaturGlaetten",
"caption": "Boilertemperatur mit PT1 glaetten"
},
{
"type": "NumberSpinner",
"name": "ZeitKonstante",
"caption": "PT1-Zeitkonstante",
"suffix": " s",
"minimum": 1
},
{
"type": "NumberSpinner",
"name": "Boilervolumen",
"caption": "Boilervolumen",
"suffix": " l",
"minimum": 1
},
{
"type": "NumberSpinner",
"name": "Hysterese",
"caption": "Temperaturhysterese",
"suffix": " K",
"minimum": 0.1,
"digits": 1
},
{
"type": "CheckBox",
"name": "EinstellungenInVisu",
"caption": "Temperatursollwerte in der Visualisierung anzeigen und bedienen"
}
]
},
{
"type": "ExpansionPanel",
"caption": "Zeitplan",
"items": [
{
"type": "List",
"name": "Zeitplan",
"caption": "Naechste Solltemperatur vorausberechnen",
"add": true,
"delete": true,
"sortable": true,
"columns": [
{
"caption": "Uhrzeit",
"name": "Uhrzeit",
"width": "150px",
"add": "00:00",
"edit": {
"type": "ValidationTextBox"
}
},
{
"caption": "Solltemperatur",
"name": "Solltemperatur",
"width": "180px",
"add": 50,
"edit": {
"type": "NumberSpinner",
"minimum": 0,
"maximum": 100,
"digits": 1,
"suffix": " C"
}
}
]
}
]
},
{
"type": "ExpansionPanel",
"caption": "Legionellenfunktion",
"items": [
{
"type": "CheckBox",
"name": "LegionellenfunktionAktiv",
"caption": "Legionellenfunktion aktiv"
},
{
"type": "NumberSpinner",
"name": "LegionellenIntervallTage",
"caption": "Intervall",
"suffix": " Tage",
"minimum": 1
},
{
"type": "NumberSpinner",
"name": "LegionellenHaltezeit",
"caption": "Haltezeit auf Legionellentemperatur",
"suffix": " s",
"minimum": 0
},
{
"type": "NumberSpinner",
"name": "LegionellenMaximaldauer",
"caption": "Maximaldauer",
"suffix": " s",
"minimum": 1
},
{
"type": "NumberSpinner",
"name": "NachtstartStunde",
"caption": "Nachtbeginn",
"suffix": " Uhr",
"minimum": 0,
"maximum": 23
},
{
"type": "NumberSpinner",
"name": "NachtendeStunde",
"caption": "Nachtende",
"suffix": " Uhr",
"minimum": 0,
"maximum": 23
}
]
},
{
"type": "ExpansionPanel",
"caption": "Diagnose",
"items": [
{
"type": "CheckBox",
"name": "DiagnosevariablenAnzeigen",
"caption": "Diagnosevariablen anzeigen"
},
{
"type": "CheckBox",
"name": "LoggingEin",
"caption": "Laufendes Debug-Logging aktivieren"
}
]
}
],
"actions": [
{
"type": "Button",
"caption": "Regelung jetzt ausfuehren",
"onClick": "IPS_RequestAction($id, 'Regelzyklus', false);"
},
{
"type": "Button",
"caption": "Aktuellen Status einmalig ins Debug-Log schreiben",
"onClick": "IPS_RequestAction($id, 'LogStatus', true);"
}
],
"status": [
{
"code": 102,
"icon": "active",
"caption": "Aktiv"
},
{
"code": 201,
"icon": "inactive",
"caption": "Temperaturmessung fehlt oder ist veraltet"
},
{
"code": 202,
"icon": "error",
"caption": "Konfiguration ungueltig"
},
{
"code": 203,
"icon": "error",
"caption": "Schaltfehler"
}
]
}
+15
View File
@@ -0,0 +1,15 @@
{
"id": "{B7C54AF4-AD7D-4FE4-B75D-203693906251}",
"name": "VerbraucherWarmwassererwaermer",
"type": 3,
"vendor": "Belevo AG",
"aliases": [
"Enelix 2 Verbraucher Warmwassererwaermer",
"Enelix 2 Wassererwaermer"
],
"parentRequirements": [],
"childRequirements": [],
"implemented": [],
"prefix": "ENELIX",
"url": "https://git.belevo.ch/ENELIX/Enelix-EMS/src/branch/develop/Warmwassererwaermer"
}
+898
View File
@@ -0,0 +1,898 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../libs/VerbraucherSchnittstelle.php';
require_once __DIR__ . '/../libs/VerbraucherBasisTrait.php';
require_once __DIR__ . '/../libs/Nachrichtenvertrag.php';
require_once __DIR__ . '/../libs/WarmwasserRegler.php';
use Belevo\EnelixEMS\Nachrichtenvertrag;
use Belevo\EnelixEMS\VerbraucherBasisTrait;
use Belevo\EnelixEMS\VerbraucherSchnittstelle;
use Belevo\EnelixEMS\WarmwasserRegler;
class VerbraucherWarmwassererwaermer extends IPSModule implements VerbraucherSchnittstelle
{
use VerbraucherBasisTrait;
private const MANAGER_MODULE_ID = '{6F771B18-59D4-4C8A-B951-3B2FE9F6A2C4}';
private const STATUS_AKTIV = 102;
private const STATUS_TEMPERATUR_UNGUELTIG = 201;
private const STATUS_KONFIGURATION_UNGUELTIG = 202;
private const STATUS_SCHALTFEHLER = 203;
private const VM_UPDATE = 10603;
/** @var list<string> */
private const DIAGNOSE_VARIABLEN = [
'Leistungsquelle',
'SollwertGueltig',
'Verfuegbar',
'AenderungMoeglich',
'TemperaturGueltig',
'NachladenAktiv',
'LegionellenbetriebAktiv',
'LegioCounter',
'Idle',
'IdleCounter',
];
public function Create(): void
{
parent::Create();
$this->registriereVerbraucherBasis();
// Aus Boiler_x_Stufig uebernommene und sicher ergaenzte Konfiguration.
$this->RegisterPropertyString('LeistungsStufen', '[]');
$this->RegisterPropertyInteger('ZeitKonstante', 120);
$this->RegisterPropertyInteger('Boilerfuehler_PT1', 0);
$this->RegisterPropertyBoolean('BoilertemperaturGlaetten', false);
$this->RegisterPropertyInteger('Boilervolumen', 300);
$this->RegisterPropertyString('Zeitplan', '[]');
$this->RegisterPropertyInteger('Interval', 5);
$this->RegisterPropertyInteger('IdleCounterMax', 2);
$this->RegisterPropertyInteger('TemperaturMaxAlter', 30);
$this->RegisterPropertyFloat('Hysterese', 5.0);
$this->RegisterPropertyBoolean('LegionellenfunktionAktiv', true);
$this->RegisterPropertyInteger('LegionellenIntervallTage', 7);
$this->RegisterPropertyInteger('LegionellenHaltezeit', 0);
$this->RegisterPropertyInteger('LegionellenMaximaldauer', 86400);
$this->RegisterPropertyInteger('NachtstartStunde', 22);
$this->RegisterPropertyInteger('NachtendeStunde', 7);
$this->RegisterPropertyBoolean('DiagnosevariablenAnzeigen', false);
$this->RegisterVariableFloat('Boilertemperatur', 'Boilertemperatur', '~Temperature', 100);
$this->RegisterVariableInteger('AktiveStufe', 'Aktive Stufe', '', 110);
$this->RegisterVariableFloat('BezogeneEnergie', 'Bezogene Energie', '~Electricity', 120);
$this->RegisterVariableFloat('Mindesttemperatur', 'Mindesttemperatur', '~Temperature', 130);
$this->RegisterVariableFloat('Maximaltemperatur', 'Maximaltemperatur', '~Temperature', 140);
$this->RegisterVariableFloat('Legionellentemperatur', 'Legionellentemperatur', '~Temperature', 150);
$this->EnableAction('Mindesttemperatur');
$this->EnableAction('Maximaltemperatur');
$this->EnableAction('Legionellentemperatur');
$this->RegisterVariableBoolean('TemperaturGueltig', 'Temperatur gueltig', '~Switch', 200);
$this->RegisterVariableBoolean('NachladenAktiv', 'Nachladen aktiv', '~Switch', 210);
$this->RegisterVariableBoolean(
'LegionellenbetriebAktiv',
'Legionellenbetrieb aktiv',
'~Switch',
220
);
$this->RegisterVariableInteger('LegioCounter', 'Zeit seit Legionellenabschluss', '', 230);
$this->RegisterVariableBoolean('Idle', 'Idle', '~Switch', 240);
$this->RegisterVariableInteger('IdleCounter', 'Idle Counter', '', 250);
$this->RegisterAttributeBoolean('InitialwerteGesetzt', false);
$this->RegisterAttributeInteger('RegistrierteFuehlerID', 0);
$this->RegisterAttributeInteger('LetzteBerechnung', 0);
$this->RegisterAttributeInteger('LetzteVorgabeZeit', 0);
$this->RegisterAttributeInteger('LetzteManagerID', 0);
$this->RegisterAttributeInteger('LetzterLegionellenabschluss', 0);
$this->RegisterAttributeInteger('LegionellenStart', 0);
$this->RegisterAttributeInteger('LegionellenSeitZiel', 0);
$this->RegisterAttributeFloat('Glaettungswert', 0.0);
$this->RegisterAttributeBoolean('GlaettungInitialisiert', false);
$this->RegisterAttributeString('Leistungsangebot', '[]');
$this->RegisterAttributeString('Schaltfehler', '');
$this->RegisterAttributeString('Legionellenfehler', '');
$this->RegisterTimer(
'Regelzyklus',
0,
"IPS_RequestAction(\$_IPS['TARGET'], 'Regelzyklus', 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->initialisiereTemperaturen();
$this->aktualisiereSichtbarkeit();
try {
$this->pruefeKonfiguration();
} catch (Throwable $fehler) {
$this->SetTimerInterval('Regelzyklus', 0);
$this->SetTimerInterval('Meldezyklus', 0);
$this->SetTimerInterval('RueckmeldungVerzoegert', 0);
$this->SetValue('Verfuegbar', false);
$this->SetValue('AenderungMoeglich', false);
$this->SetStatus(self::STATUS_KONFIGURATION_UNGUELTIG);
$this->protokolliere('Konfiguration', $fehler->getMessage());
return;
}
$this->registriereFuehlermeldung();
$this->SetTimerInterval('Regelzyklus', $this->ReadPropertyInteger('Interval') * 1000);
$this->SetTimerInterval('Meldezyklus', $this->ReadPropertyInteger('Meldeintervall') * 1000);
if ($this->ReadAttributeInteger('LetzterLegionellenabschluss') <= 0) {
$this->WriteAttributeInteger('LetzterLegionellenabschluss', time());
}
$this->regelzyklus(true);
}
public function MessageSink($zeitstempel, $senderID, $nachricht, $daten): void
{
if ((int) $senderID === $this->ReadPropertyInteger('Boilerfuehler_PT1')
&& (int) $nachricht === self::VM_UPDATE
) {
$this->regelzyklus(true);
}
}
public function RequestAction($ident, $wert): void
{
switch ($ident) {
case 'Aktiv':
$this->SetValue('Aktiv', (bool) $wert);
$this->regelzyklus(true);
return;
case 'Mindesttemperatur':
case 'Maximaltemperatur':
case 'Legionellentemperatur':
$this->setzeTemperaturSollwert((string) $ident, $wert);
return;
case 'Regelzyklus':
$this->regelzyklus(true);
return;
case 'Melden':
if ((bool) $wert) {
$this->SetTimerInterval('RueckmeldungVerzoegert', 0);
}
$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;
case 'LogStatus':
$this->protokolliereStatus(true);
return;
}
throw new InvalidArgumentException('Unbekannte Aktion: ' . $ident);
}
/** @param array<string, mixed> $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->regelzyklus(false);
$angebot = $this->leseLeistungsangebot();
$sollleistung = $daten['Sollleistung_W'];
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->SetValue('Sollleistung', $sollleistung);
$this->SetValue('SollwertGueltig', true);
$this->regelzyklus(false);
// Verhindert eine synchrone Manager-Verbraucher-Endlosschleife.
$this->SetTimerInterval('RueckmeldungVerzoegert', 100);
}
private function regelzyklus(bool $meldungPlanen): void
{
try {
$jetzt = time();
$this->aktualisiereEnergie($jetzt);
$temperaturGueltig = $this->aktualisiereTemperatur();
$this->aktualisiereLegionellenstatus($temperaturGueltig, $jetzt);
[$angebot, $aenderungMoeglich, $verfuegbar, $zwangsleistung] =
$this->bestimmeLeistungsangebot($temperaturGueltig, $jetzt);
$this->WriteAttributeString('Leistungsangebot', json_encode($angebot, JSON_THROW_ON_ERROR));
$this->SetValue('Verfuegbar', $verfuegbar);
$this->SetValue('AenderungMoeglich', $aenderungMoeglich);
$letzteVorgabe = $this->ReadAttributeInteger('LetzteVorgabeZeit');
if ($letzteVorgabe <= 0
|| $jetzt - $letzteVorgabe > $this->ReadPropertyInteger('VorgabeTimeout')
) {
$this->SetValue('SollwertGueltig', false);
}
$ziel = 0;
if ($verfuegbar) {
if ($zwangsleistung !== null) {
$ziel = $zwangsleistung;
} elseif ((bool) $this->GetValue('SollwertGueltig')) {
$ziel = (int) $this->GetValue('Sollleistung');
if (!in_array($ziel, $angebot, true)) {
$this->SetValue('SollwertGueltig', false);
$ziel = 0;
}
}
}
$this->schalteLeistung($ziel);
$this->SetValue('Sollleistung', $ziel);
$this->aktualisiereStoerungen($temperaturGueltig);
if ($meldungPlanen) {
$this->SetTimerInterval('RueckmeldungVerzoegert', 100);
}
} catch (Throwable $fehler) {
$this->WriteAttributeString('Schaltfehler', $fehler->getMessage());
$this->SetValue('Verfuegbar', false);
$this->SetValue('AenderungMoeglich', false);
$this->SetValue('Stoerung', true);
$this->SetValue('Stoertext', $fehler->getMessage());
$this->SetStatus(self::STATUS_SCHALTFEHLER);
$this->protokolliere('Regelzyklus', $fehler->getMessage());
}
}
private function aktualisiereTemperatur(): bool
{
$fuehlerID = $this->ReadPropertyInteger('Boilerfuehler_PT1');
if ($fuehlerID <= 0 || !IPS_VariableExists($fuehlerID)) {
$this->SetValue('TemperaturGueltig', false);
return false;
}
$variable = IPS_GetVariable($fuehlerID);
if (time() - (int) $variable['VariableUpdated'] > $this->ReadPropertyInteger('TemperaturMaxAlter')) {
$this->SetValue('TemperaturGueltig', false);
return false;
}
$rohwert = GetValue($fuehlerID);
if (!is_int($rohwert) && !is_float($rohwert)) {
$this->SetValue('TemperaturGueltig', false);
return false;
}
$temperatur = (float) $rohwert;
if ($this->ReadPropertyBoolean('BoilertemperaturGlaetten')) {
if (!$this->ReadAttributeBoolean('GlaettungInitialisiert')) {
$this->WriteAttributeFloat('Glaettungswert', $temperatur);
$this->WriteAttributeBoolean('GlaettungInitialisiert', true);
} else {
$temperatur = WarmwasserRegler::pt1(
$this->ReadAttributeFloat('Glaettungswert'),
$temperatur,
$this->ReadPropertyInteger('Interval'),
$this->ReadPropertyInteger('ZeitKonstante')
);
$this->WriteAttributeFloat('Glaettungswert', $temperatur);
}
} else {
$this->WriteAttributeBoolean('GlaettungInitialisiert', false);
}
$this->SetValue('Boilertemperatur', $temperatur);
$this->SetValue('TemperaturGueltig', true);
return true;
}
/**
* @return array{0: list<int>, 1: bool, 2: bool, 3: int|null}
*/
private function bestimmeLeistungsangebot(bool $temperaturGueltig, int $jetzt): array
{
$stufen = WarmwasserRegler::dekodiereLeistungsstufen($this->ReadPropertyString('LeistungsStufen'));
if (!(bool) $this->GetValue('Aktiv') || !$temperaturGueltig || $stufen === []) {
$this->SetValue('NachladenAktiv', false);
return [[], false, false, 0];
}
$alleLeistungen = WarmwasserRegler::leistungswerte($stufen);
$maximalleistung = max($alleLeistungen);
$temperatur = (float) $this->GetValue('Boilertemperatur');
$minimum = (float) $this->GetValue('Mindesttemperatur');
$maximum = (float) $this->GetValue('Maximaltemperatur');
$hysterese = $this->ReadPropertyFloat('Hysterese');
$aktuelleLeistung = (int) round((float) $this->GetValue('Istleistung'));
$nachladen = (bool) $this->GetValue('NachladenAktiv');
if ($temperatur < $minimum) {
$nachladen = true;
} elseif ($temperatur >= $minimum + $hysterese) {
$nachladen = false;
}
$this->SetValue('NachladenAktiv', $nachladen);
$zeitplanErfordertHeizen = false;
$naechster = WarmwasserRegler::naechsterZeitplaneintrag(
$this->ReadPropertyString('Zeitplan'),
new DateTimeImmutable('now')
);
if ($naechster !== null && $temperatur < $naechster['Solltemperatur']) {
$zeitplanErfordertHeizen = !WarmwasserRegler::kannZielErreichen(
(float) $this->ReadPropertyInteger('Boilervolumen'),
$temperatur,
$naechster['Solltemperatur'],
max(0, $naechster['Zeitpunkt'] - $jetzt),
$maximalleistung
);
}
if ((bool) $this->GetValue('LegionellenbetriebAktiv') || $nachladen || $zeitplanErfordertHeizen) {
return [[$maximalleistung], false, true, $maximalleistung];
}
if ($temperatur >= $maximum) {
return [[0], false, true, 0];
}
if ($aktuelleLeistung > 0 && $temperatur >= $maximum - $hysterese) {
return [[$aktuelleLeistung], false, true, $aktuelleLeistung];
}
return [$alleLeistungen, true, true, null];
}
private function aktualisiereLegionellenstatus(bool $temperaturGueltig, int $jetzt): void
{
$letzterAbschluss = $this->ReadAttributeInteger('LetzterLegionellenabschluss');
$this->SetValue('LegioCounter', max(0, $jetzt - $letzterAbschluss));
if (!$this->ReadPropertyBoolean('LegionellenfunktionAktiv')) {
$this->SetValue('LegionellenbetriebAktiv', false);
$this->WriteAttributeInteger('LegionellenStart', 0);
$this->WriteAttributeInteger('LegionellenSeitZiel', 0);
$this->WriteAttributeString('Legionellenfehler', '');
return;
}
$aktiv = (bool) $this->GetValue('LegionellenbetriebAktiv');
$faellig = $jetzt - $letzterAbschluss >= $this->ReadPropertyInteger('LegionellenIntervallTage') * 86400;
if (!$aktiv && $faellig && $this->istNachtstunde((int) date('G', $jetzt))) {
$aktiv = true;
$this->WriteAttributeInteger('LegionellenStart', $jetzt);
$this->WriteAttributeInteger('LegionellenSeitZiel', 0);
}
if ($aktiv && $jetzt - $this->ReadAttributeInteger('LegionellenStart')
> $this->ReadPropertyInteger('LegionellenMaximaldauer')
) {
$aktiv = false;
$this->WriteAttributeInteger('LetzterLegionellenabschluss', $jetzt);
$this->WriteAttributeString(
'Legionellenfehler',
'Legionellenprogramm innerhalb der Maximaldauer nicht abgeschlossen.'
);
}
if ($aktiv && $temperaturGueltig
&& (float) $this->GetValue('Boilertemperatur') >= (float) $this->GetValue('Legionellentemperatur')
) {
$seitZiel = $this->ReadAttributeInteger('LegionellenSeitZiel');
if ($seitZiel <= 0) {
$seitZiel = $jetzt;
$this->WriteAttributeInteger('LegionellenSeitZiel', $seitZiel);
}
if ($jetzt - $seitZiel >= $this->ReadPropertyInteger('LegionellenHaltezeit')) {
$aktiv = false;
$this->WriteAttributeInteger('LetzterLegionellenabschluss', $jetzt);
$this->WriteAttributeInteger('LegionellenStart', 0);
$this->WriteAttributeInteger('LegionellenSeitZiel', 0);
$this->WriteAttributeString('Legionellenfehler', '');
}
}
$this->SetValue('LegionellenbetriebAktiv', $aktiv);
}
private function schalteLeistung(int $zielLeistung): void
{
$stufen = WarmwasserRegler::dekodiereLeistungsstufen($this->ReadPropertyString('LeistungsStufen'));
$zielStufe = WarmwasserRegler::stufeFuerLeistung($stufen, $zielLeistung);
$bisherigeLeistung = (int) round((float) $this->GetValue('Istleistung'));
if ($zielLeistung === $bisherigeLeistung && $this->kontakteEntsprechenZiel($stufen, $zielLeistung)) {
$this->WriteAttributeString('Schaltfehler', '');
$this->verarbeiteIdle(false);
return;
}
try {
// Break-before-make: zuerst alle Kontakte aus, danach genau eine Stufe ein.
foreach ($stufen as $stufe) {
RequestAction($stufe['KontaktID'], false);
}
if ($zielLeistung > 0) {
foreach ($stufen as $stufe) {
if ($stufe['Leistung_W'] === $zielLeistung) {
RequestAction($stufe['KontaktID'], true);
break;
}
}
}
} catch (Throwable $fehler) {
foreach ($stufen as $stufe) {
try {
RequestAction($stufe['KontaktID'], false);
} catch (Throwable $ignoriert) {
}
}
$this->SetValue('Istleistung', 0.0);
$this->SetValue('AktiveStufe', 0);
throw new RuntimeException('Leistungsstufe konnte nicht sicher geschaltet werden: ' . $fehler->getMessage());
}
$this->WriteAttributeString('Schaltfehler', '');
$this->SetValue('Istleistung', (float) $zielLeistung);
$this->SetValue('Leistungsquelle', Nachrichtenvertrag::LEISTUNGSQUELLE_BERECHNET);
$this->SetValue('AktiveStufe', $zielStufe);
$this->verarbeiteIdle(true);
$this->protokolliere('Leistungsstufe', ['Leistung_W' => $zielLeistung, 'Stufe' => $zielStufe]);
}
/**
* @param list<array{Stufe: int, Leistung_W: int, KontaktID: int}> $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 verarbeiteIdle(bool $geaendert): void
{
$counter = (int) $this->GetValue('IdleCounter');
if ($geaendert) {
$counter = $this->ReadPropertyInteger('IdleCounterMax');
} elseif ($counter > 0) {
--$counter;
}
$this->SetValue('IdleCounter', $counter);
$this->SetValue('Idle', $counter === 0);
}
private function aktualisiereEnergie(int $jetzt): void
{
$letzteBerechnung = $this->ReadAttributeInteger('LetzteBerechnung');
$this->WriteAttributeInteger('LetzteBerechnung', $jetzt);
if ($letzteBerechnung <= 0 || $jetzt <= $letzteBerechnung) {
return;
}
$zuwachs = WarmwasserRegler::energieKWh(
(float) $this->GetValue('Istleistung'),
$jetzt - $letzteBerechnung
);
$this->SetValue('BezogeneEnergie', (float) $this->GetValue('BezogeneEnergie') + $zuwachs);
}
private function aktualisiereStoerungen(bool $temperaturGueltig): void
{
$stoerungen = [];
if (!$temperaturGueltig) {
$stoerungen[] = 'Boilertemperatur fehlt oder ist veraltet.';
}
$uebertemperatur = $temperaturGueltig
&& (float) $this->GetValue('Boilertemperatur')
> max(
(float) $this->GetValue('Maximaltemperatur'),
(float) $this->GetValue('Legionellentemperatur')
) + $this->ReadPropertyFloat('Hysterese');
if ($uebertemperatur) {
$stoerungen[] = 'Uebertemperatur erkannt.';
}
if ($this->ReadAttributeString('Schaltfehler') !== '') {
$stoerungen[] = $this->ReadAttributeString('Schaltfehler');
}
if ($this->ReadAttributeString('Legionellenfehler') !== '') {
$stoerungen[] = $this->ReadAttributeString('Legionellenfehler');
}
$this->SetValue('Stoerung', $stoerungen !== []);
$this->SetValue('Stoertext', implode("\n", array_values(array_unique($stoerungen))));
if ($this->ReadAttributeString('Schaltfehler') !== '') {
$this->SetStatus(self::STATUS_SCHALTFEHLER);
} elseif (!$temperaturGueltig) {
$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<string, mixed> */
private function baueVerbraucherdaten(int $managerID): array
{
$temperaturGueltig = (bool) $this->GetValue('TemperaturGueltig');
$schaltfehler = $this->ReadAttributeString('Schaltfehler');
$legionellenfehler = $this->ReadAttributeString('Legionellenfehler');
$uebertemperatur = $temperaturGueltig
&& (float) $this->GetValue('Boilertemperatur')
> max(
(float) $this->GetValue('Maximaltemperatur'),
(float) $this->GetValue('Legionellentemperatur')
) + $this->ReadPropertyFloat('Hysterese');
return [
'Kopf' => [
'Version' => Nachrichtenvertrag::VERSION,
'AbsenderID' => $this->InstanceID,
'EmpfaengerID' => $managerID,
'Zeitpunkt' => time(),
],
'PrioritaetPV' => $this->ReadPropertyInteger('PrioritaetPV'),
'PrioritaetPeak' => $this->ReadPropertyInteger('PrioritaetPeak'),
'Leistungswerte_W' => $this->leseLeistungsangebot(),
'AenderungMoeglich' => (bool) $this->GetValue('AenderungMoeglich'),
'Verfuegbar' => (bool) $this->GetValue('Verfuegbar'),
'Istleistung_W' => (float) $this->GetValue('Istleistung'),
'Leistungsquelle' => Nachrichtenvertrag::LEISTUNGSQUELLE_BERECHNET,
'Zustand' => [
[
'Kennung' => 'Sollleistung_W',
'Art' => 'Sollwert',
'Wert' => (bool) $this->GetValue('SollwertGueltig')
? (int) $this->GetValue('Sollleistung')
: null,
'Einheit' => 'W',
],
[
'Kennung' => 'Wassertemperatur_C',
'Art' => 'Istwert',
'Wert' => $temperaturGueltig ? (float) $this->GetValue('Boilertemperatur') : null,
'Einheit' => 'C',
],
[
'Kennung' => 'Maximaltemperatur_C',
'Art' => 'Sollwert',
'Wert' => (float) $this->GetValue('Maximaltemperatur'),
'Einheit' => 'C',
],
[
'Kennung' => 'AktiveStufe',
'Art' => 'Status',
'Wert' => (int) $this->GetValue('AktiveStufe'),
'Einheit' => '',
],
[
'Kennung' => 'NachladenAktiv',
'Art' => 'Status',
'Wert' => (bool) $this->GetValue('NachladenAktiv'),
'Einheit' => '',
],
[
'Kennung' => 'LegionellenbetriebAktiv',
'Art' => 'Status',
'Wert' => (bool) $this->GetValue('LegionellenbetriebAktiv'),
'Einheit' => '',
],
[
'Kennung' => 'Fuehlerfehler',
'Art' => 'Stoerung',
'Wert' => !$temperaturGueltig,
'Einheit' => '',
'Text' => $temperaturGueltig ? '' : 'Boilertemperatur fehlt oder ist veraltet.',
],
[
'Kennung' => 'Uebertemperatur',
'Art' => 'Stoerung',
'Wert' => $uebertemperatur,
'Einheit' => '',
'Text' => $uebertemperatur ? 'Uebertemperatur erkannt.' : '',
],
[
'Kennung' => 'Schaltfehler',
'Art' => 'Stoerung',
'Wert' => $schaltfehler !== '',
'Einheit' => '',
'Text' => $schaltfehler,
],
[
'Kennung' => 'Legionellenfehler',
'Art' => 'Stoerung',
'Wert' => $legionellenfehler !== '',
'Einheit' => '',
'Text' => $legionellenfehler,
],
],
];
}
/** @return list<int> */
private function zugeordneteManagerIDs(): array
{
$ergebnis = [];
foreach (IPS_GetInstanceListByModuleID(self::MANAGER_MODULE_ID) as $managerID) {
try {
$zuordnung = json_decode(
(string) IPS_GetProperty($managerID, 'VerbraucherZuordnung'),
true,
512,
JSON_THROW_ON_ERROR
);
} catch (Throwable $fehler) {
continue;
}
if (!is_array($zuordnung)) {
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));
}
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', 'Interval', 'IdleCounterMax', 'TemperaturMaxAlter',
'ZeitKonstante', 'Boilervolumen', 'LegionellenIntervallTage', 'LegionellenMaximaldauer',
] as $property) {
if ($this->ReadPropertyInteger($property) <= 0) {
throw new InvalidArgumentException($property . ' muss groesser als 0 sein.');
}
}
if ($this->ReadPropertyInteger('LegionellenHaltezeit') < 0) {
throw new InvalidArgumentException('LegionellenHaltezeit darf nicht negativ sein.');
}
if ($this->ReadPropertyFloat('Hysterese') <= 0) {
throw new InvalidArgumentException('Hysterese muss groesser als 0 sein.');
}
foreach (['NachtstartStunde', 'NachtendeStunde'] as $property) {
$stunde = $this->ReadPropertyInteger($property);
if ($stunde < 0 || $stunde > 23) {
throw new InvalidArgumentException($property . ' muss zwischen 0 und 23 liegen.');
}
}
$minimum = (float) $this->GetValue('Mindesttemperatur');
$maximum = (float) $this->GetValue('Maximaltemperatur');
$legionellen = (float) $this->GetValue('Legionellentemperatur');
if ($minimum < 0 || $minimum >= $maximum || $legionellen < $maximum) {
throw new InvalidArgumentException(
'Temperaturen muessen Mindesttemperatur < Maximaltemperatur <= Legionellentemperatur erfuellen.'
);
}
$fuehlerID = $this->ReadPropertyInteger('Boilerfuehler_PT1');
if ($fuehlerID <= 0 || !IPS_VariableExists($fuehlerID)) {
throw new InvalidArgumentException('Boilerfuehler_PT1 ist nicht eingerichtet.');
}
$fuehler = IPS_GetVariable($fuehlerID);
if (!in_array((int) $fuehler['VariableType'], [1, 2], true)) {
throw new InvalidArgumentException('Boilerfuehler_PT1 muss eine Integer- oder Floatvariable sein.');
}
$stufen = WarmwasserRegler::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.');
}
}
WarmwasserRegler::naechsterZeitplaneintrag(
$this->ReadPropertyString('Zeitplan'),
new DateTimeImmutable('now')
);
}
private function registriereFuehlermeldung(): void
{
$alt = $this->ReadAttributeInteger('RegistrierteFuehlerID');
$neu = $this->ReadPropertyInteger('Boilerfuehler_PT1');
if ($alt > 0 && $alt !== $neu) {
$this->UnregisterMessage($alt, self::VM_UPDATE);
}
if ($neu > 0 && $neu !== $alt) {
$this->RegisterMessage($neu, self::VM_UPDATE);
}
$this->WriteAttributeInteger('RegistrierteFuehlerID', $neu);
}
private function initialisiereTemperaturen(): void
{
if ($this->ReadAttributeBoolean('InitialwerteGesetzt')) {
return;
}
$this->SetValue('Mindesttemperatur', 45.0);
$this->SetValue('Maximaltemperatur', 60.0);
$this->SetValue('Legionellentemperatur', 65.0);
$this->SetValue('Leistungsquelle', Nachrichtenvertrag::LEISTUNGSQUELLE_BERECHNET);
$this->SetValue('Idle', true);
$this->WriteAttributeBoolean('InitialwerteGesetzt', true);
}
/** @param mixed $wert */
private function setzeTemperaturSollwert(string $ident, $wert): void
{
if (!$this->ReadPropertyBoolean('EinstellungenInVisu')) {
throw new RuntimeException('Temperatureinstellungen sind in der Visualisierung nicht freigegeben.');
}
if (!is_int($wert) && !is_float($wert)) {
throw new InvalidArgumentException('Temperatursollwert muss numerisch sein.');
}
$temperatur = (float) $wert;
if ($temperatur < 0 || $temperatur > 100) {
throw new InvalidArgumentException('Temperatursollwert muss zwischen 0 und 100 Grad C liegen.');
}
$werte = [
'Mindesttemperatur' => (float) $this->GetValue('Mindesttemperatur'),
'Maximaltemperatur' => (float) $this->GetValue('Maximaltemperatur'),
'Legionellentemperatur' => (float) $this->GetValue('Legionellentemperatur'),
];
$werte[$ident] = $temperatur;
if ($werte['Mindesttemperatur'] >= $werte['Maximaltemperatur']
|| $werte['Maximaltemperatur'] > $werte['Legionellentemperatur']
) {
throw new InvalidArgumentException(
'Temperaturen muessen Mindesttemperatur < Maximaltemperatur <= Legionellentemperatur erfuellen.'
);
}
$this->SetValue($ident, $temperatur);
$this->regelzyklus(true);
}
private function aktualisiereSichtbarkeit(): void
{
$diagnose = $this->ReadPropertyBoolean('DiagnosevariablenAnzeigen');
foreach (self::DIAGNOSE_VARIABLEN as $ident) {
IPS_SetHidden($this->GetIDForIdent($ident), !$diagnose);
}
$einstellungen = $this->ReadPropertyBoolean('EinstellungenInVisu');
foreach (['Mindesttemperatur', 'Maximaltemperatur', 'Legionellentemperatur'] as $ident) {
IPS_SetHidden($this->GetIDForIdent($ident), !$einstellungen);
if ($einstellungen) {
$this->EnableAction($ident);
} else {
$this->DisableAction($ident);
}
}
}
/** @return list<int> */
private function leseLeistungsangebot(): array
{
$angebot = json_decode($this->ReadAttributeString('Leistungsangebot'), true, 512, JSON_THROW_ON_ERROR);
if (!is_array($angebot)) {
return [];
}
return array_values(array_map('intval', $angebot));
}
private function istNachtstunde(int $stunde): bool
{
$start = $this->ReadPropertyInteger('NachtstartStunde');
$ende = $this->ReadPropertyInteger('NachtendeStunde');
if ($start === $ende) {
return true;
}
if ($start < $ende) {
return $stunde >= $start && $stunde < $ende;
}
return $stunde >= $start || $stunde < $ende;
}
private function protokolliereStatus(bool $erzwingen): void
{
if (!$erzwingen && !$this->ReadPropertyBoolean('LoggingEin')) {
return;
}
$this->SendDebug('Status', json_encode([
'Aktiv' => (bool) $this->GetValue('Aktiv'),
'Boilertemperatur_C' => (float) $this->GetValue('Boilertemperatur'),
'TemperaturGueltig' => (bool) $this->GetValue('TemperaturGueltig'),
'Leistungsangebot_W' => $this->leseLeistungsangebot(),
'Sollleistung_W' => (int) $this->GetValue('Sollleistung'),
'Istleistung_W' => (float) $this->GetValue('Istleistung'),
'AktiveStufe' => (int) $this->GetValue('AktiveStufe'),
'Stoertext' => (string) $this->GetValue('Stoertext'),
], JSON_THROW_ON_ERROR), 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
);
}
}
+1 -1
View File
@@ -100,7 +100,7 @@ Symcon-Datenpunkte werden durch `VerbraucherBasisTrait` registriert.
| `Meldeintervall` | Integer | `10` | Vollständige Rückmeldung in Sekunden; muss grösser als 0 sein. | | `Meldeintervall` | Integer | `10` | Vollständige Rückmeldung in Sekunden; muss grösser als 0 sein. |
| `VorgabeTimeout` | Integer | `30` | Sollwert wird nach dieser Zeit ohne Erneuerung ungültig. | | `VorgabeTimeout` | Integer | `30` | Sollwert wird nach dieser Zeit ohne Erneuerung ungültig. |
| `EinstellungenInVisu` | Boolean | `false` | Blendet lokale Einstellvariablen ein und macht sie geprüft bedienbar. | | `EinstellungenInVisu` | Boolean | `false` | Blendet lokale Einstellvariablen ein und macht sie geprüft bedienbar. |
| `LoggingEin` | Boolean | `false` | Aktiviert Diagnoseprotokoll und zusätzliche Diagnosevariablen. | | `LoggingEin` | Boolean | `false` | Aktiviert das laufende Diagnoseprotokoll. Module können zusätzliche Diagnosevariablen getrennt einblenden. |
### Gemeinsame Variablen aller Verbraucher ### Gemeinsame Variablen aller Verbraucher
@@ -0,0 +1,24 @@
# ADR 0001: Warmwassererwaermer verwendet ausschliesslich Vertrag 3.0
## Status
Akzeptiert.
## Entscheidung
Der neue `VerbraucherWarmwassererwaermer` implementiert die bestehende
`VerbraucherSchnittstelle` und den `Nachrichtenvertrag` in Version `3.0`.
Die Enelix-1-Variablen `Power`, `PowerSteps`, `PV_Prio`, `Sperre_Prio`,
`Is_Peak_Shaving` und `Leistung_Delta` werden nicht als zweite parallele
Manager-Schnittstelle weitergefuehrt.
Der Manager bleibt allein fuer die Betriebsart und Leistungsverteilung
verantwortlich. Lokale Temperatur- und Hygienesicherheit wird als eingeschraenktes
Leistungsangebot mit `AenderungMoeglich=false` gemeldet.
## Folgen
- Es gibt genau eine fachliche Empfangsmethode pro Richtung.
- Der Verbraucher hat keine Manager-ID-Property.
- Alte Instanzen werden anhand der Migrationstabelle neu konfiguriert.
- Manager und Verbraucher koennen unabhaengig getestet werden.
+49
View File
@@ -0,0 +1,49 @@
# Migration von Boiler_x_Stufig
## Eigenschaften
| Enelix 1 | Enelix 2 | Hinweis |
| --- | --- | --- |
| `LeistungsStufen` | `LeistungsStufen` | Felder `Stufe`, `Leistung`, `Schaltkontakt_Stufe` bleiben erhalten. |
| `ZeitKonstante` | `ZeitKonstante` | PT1-Berechnung verwendet das reale Regelintervall. |
| `Boilerfuehler_PT1` | `Boilerfuehler_PT1` | Alter und Datentyp werden geprueft. |
| `Boilertemperatur_glätten` | `BoilertemperaturGlaetten` | ASCII-sicherer Ident. |
| `Boilervolumen` | `Boilervolumen` | Wird fuer die Zeitplanprognose verwendet. |
| `Zeitplan` | `Zeitplan` | JSON-Liste mit `Uhrzeit` und `Solltemperatur`. |
| `Interval` | `Interval` | Regelintervall in Sekunden. |
| `IdleCounterMax` | `IdleCounterMax` | Verhalten bleibt erhalten. |
Die neuen Properties `TemperaturMaxAlter`, `Hysterese`, die Parameter der
Legionellenfunktion und `DiagnosevariablenAnzeigen` ersetzen feste Werte und
nicht pruefbare Annahmen des Altmoduls.
## Variablen
| Enelix 1 | Enelix 2 |
| --- | --- |
| `Mindesttemperatur` | `Mindesttemperatur` |
| `Maximaltemperatur` | `Maximaltemperatur` |
| `Legionellentemperatur` | `Legionellentemperatur` |
| `Boilertemperatur` | `Boilertemperatur` |
| `LegioCounter` | `LegioCounter` |
| `Idle` | `Idle` |
| `IdleCounter` | `IdleCounter` |
| `Aktuelle_Leistung` | gemeinsames `Istleistung` |
| `Bezogene_Energie` | `BezogeneEnergie` in kWh |
| `PV_Prio` | Property `PrioritaetPV` |
| `Sperre_Prio` | Property `PrioritaetPeak` |
| `Power` | gemeinsames `Sollleistung` |
| `PowerSteps` | Vertragsfeld `Leistungswerte_W`, keine Variable |
| `Is_Peak_Shaving` | entfaellt; Betriebsart liegt im Manager |
| `Leistung_Delta` | entfaellt; Leistungsverteilung liegt im Manager |
## Inbetriebnahme
1. Neue Instanz `VerbraucherWarmwassererwaermer` anlegen.
2. Fuehler, Leistungsstufen und Schaltkontakte uebertragen.
3. Temperaturvariablen einmalig mit den bisherigen Sollwerten setzen.
4. Die Instanz im Enelix-2-Manager unter `VerbraucherZuordnung` aktiv zuordnen.
5. Erst danach `Aktiv` einschalten und jede Stufe beaufsichtigt pruefen.
Das Modul verwendet eine neue Modul-ID. Eine automatische Umwandlung der alten
Instanz findet deshalb nicht statt.
+3 -3
View File
@@ -1,7 +1,7 @@
# EMS-Module und Modulentwürfe # EMS-Module und Modulentwürfe
> Der Manager ist als installierbares IP-Symcon-Modul umgesetzt. Die weiteren > Manager und Warmwassererwaermer sind als installierbare IP-Symcon-Module
> Ordner enthalten Besprechungsgrundlagen und noch keine installierbaren Module. > umgesetzt. Die weiteren Ordner enthalten Besprechungsgrundlagen.
Alle steuerbaren Verbraucher verwenden die gemeinsamen Datenpunkte aus der Alle steuerbaren Verbraucher verwenden die gemeinsamen Datenpunkte aus der
[EMS-Schnittstelle](../Schnittstelle.md). In den Modul-READMEs stehen deshalb [EMS-Schnittstelle](../Schnittstelle.md). In den Modul-READMEs stehen deshalb
@@ -11,7 +11,7 @@ nur zusätzliche Properties, Variablen, Zustände und offene Punkte.
| --- | --- | | --- | --- |
| [Manager](Manager/README.md) | Leistungsverteilung und Anschlüsse (implementiert) | | [Manager](Manager/README.md) | Leistungsverteilung und Anschlüsse (implementiert) |
| [Batterie](Batterie/README.md) | Laden und Entladen eines Speichers | | [Batterie](Batterie/README.md) | Laden und Entladen eines Speichers |
| [Wassererwärmer](Wassererwaermer/README.md) | Mehrstufige elektrische Erwärmung | | [Wassererwärmer](Wassererwaermer/README.md) | Mehrstufige elektrische Erwärmung (implementiert) |
| [Pufferspeicher](Pufferspeicher/README.md) | Heizstufen nach Heizkurve | | [Pufferspeicher](Pufferspeicher/README.md) | Heizstufen nach Heizkurve |
| [Verbraucher 1-Stufig](Verbraucher-1-Stufig/README.md) | Ein-/Aus-Verbraucher | | [Verbraucher 1-Stufig](Verbraucher-1-Stufig/README.md) | Ein-/Aus-Verbraucher |
| [Wärmepumpe](Waermepumpe/README.md) | Sperrkontakt oder SG Ready | | [Wärmepumpe](Waermepumpe/README.md) | Sperrkontakt oder SG Ready |
+87 -52
View File
@@ -1,63 +1,98 @@
# Wassererwärmer # Verbraucher Warmwassererwaermer
> Status: Diskussionsentwurf. Vereinigt Boiler x-Stufig, Boiler mit Fühler, > Status: implementiert fuer IP-Symcon 8 und den Enelix-2-Vertrag `3.0`.
> Askoheat und Heizung x-Stufig.
Anzahl, Leistung und Ausgangsbelegung werden je Stufe konfiguriert. Temperatur-, Das Modul adaptiert ausschliesslich das fruehere Modul `Boiler_x_Stufig`.
Nachlade-, Legionellen- und Askoheat-Werte sind Properties und können über Mehrere elektrische Leistungsstufen werden exklusiv geschaltet. Der Manager
`EinstellungenInVisu` bedienbar gemacht werden. waehlt eine der aktuell angebotenen Leistungen; Mindesttemperatur, Zeitplan und
Legionellenprogramm koennen die lokale Warmwassersicherheit priorisieren.
## Zusätzliche Variablen ## Sicherheit und Regelung
| Ident | Typ / Zugriff | Beschreibung | - Die Schaltkontakte werden mit Break-before-make geschaltet: zuerst alle aus,
| --- | --- | --- | danach hoechstens eine Stufe ein.
| `Wassertemperatur` | Float / Anzeige | Aktueller Wert in °C. | - Fehlende, veraltete oder nicht numerische Temperaturwerte schalten die
| `TemperaturGueltig` | Boolean / Anzeige | Adapter liefert einen gültigen Wert. | EMS-Verfuegbarkeit aus und fuehren zu einer Stoerung.
| `NachladenAktiv` | Boolean / Anzeige | Mindesttemperaturanforderung aktiv. | - Manager-Vorgaben werden nur angenommen, wenn sie im zuletzt gemeldeten
| `LegionellenbetriebAktiv` | Boolean / Anzeige | Legionellenbetrieb läuft. | `Leistungswerte_W`-Angebot enthalten sind.
| `AktiveStufe` | Integer / Anzeige | Stufenkennung; `0` bedeutet aus. | - Nach `VorgabeTimeout` wird eine nicht erneuerte Manager-Vorgabe ungueltig.
| `LetzterLegionellenabschluss` | Integer / Anzeige | Unixzeit des letzten erfolgreichen Abschlusses. | - Mindesttemperatur, vorausberechneter Zeitplan und Legionellenprogramm koennen
| `NachladeMindesttemperatur` | Float / lokal bedienbar | Wirksamer Parameter in °C. | die maximale Stufe lokal erzwingen. Diese Phase wird mit
| `NachladeMaximaltemperatur` | Float / lokal bedienbar | Wirksamer Parameter in °C. | `AenderungMoeglich=false` gemeldet.
| `Maximaltemperatur` | Float / lokal bedienbar | Normale obere Zieltemperatur in °C. | - `BoilertemperaturGlaetten` aktiviert eine PT1-Glaettung mit konfigurierbarer
| `Hysterese` | Float / lokal bedienbar | Hysterese in K. | Zeitkonstante.
| `NachladeMindesttemperaturAktiv` | Boolean / lokal bedienbar | Mindesttemperaturnachladung ein/aus. |
| `NachladeMaximaltemperaturAktiv` | Boolean / lokal bedienbar | Separates Nachlade-Endziel ein/aus. |
| `LegionellenfunktionAktiv` | Boolean / lokal bedienbar | Funktion ein/aus. |
| `Legionellentemperatur` | Float / lokal bedienbar | Zieltemperatur in °C. |
| `AskoheatFunktionAktiv` | Boolean / lokal bedienbar | Ansteuerung ein/aus. |
## Zusätzliche Properties ## Properties
| Ident | Typ | Standard / Beschreibung | Zu den sechs gemeinsamen Verbraucher-Properties aus
| --- | --- | --- | [`Schnittstelle.md`](../../Schnittstelle.md) kommen 17 Modul-Properties hinzu.
| `NachladeMindesttemperatur` | Float | `0*` °C; Starttemperatur. |
| `NachladeMaximaltemperatur` | Float | `0*` °C; Endtemperatur. |
| `Maximaltemperatur` | Float | `0*` °C; normales oberes Ziel. |
| `Hysterese` | Float | `1` K; grösser 0. |
| `NachladeMindesttemperaturAktiv` | Boolean | `false`. |
| `NachladeMaximaltemperaturAktiv` | Boolean | `false`. |
| `LegionellenfunktionAktiv` | Boolean | `false`. |
| `Legionellentemperatur` | Float | `0*` °C. |
| `AskoheatFunktionAktiv` | Boolean | `false`. |
| `Betriebsprofil` | Auswahl | `Wassererwaermer` oder `EinfacheTemperaturheizung`. |
| `Leistungsstufen` | String/JSON | `[]`; Stufenkennung, Leistung in W und vollständige Ausgangsbelegung. |
| `Volumen` | Float | `0` l; `0` deaktiviert thermische Berechnung. |
| `Mindestlaufzeit` | Integer | `0` s. |
| `Mindeststillstand` | Integer | `0` s. |
| `AskoheatInstanzID` | Integer | `0`; nur bei aktivierter Funktion. |
| `AskoheatLeistungVariableID` | Integer | `0`; nur wenn der Adapter sie benötigt. |
Temperatur-Maximalalter, Temperaturgrenze, Temperatur-Variable-ID, Zeitpläne, | Ident | Typ | Standard | Beschreibung |
Legionellenhaltezeit, Legionellenmaximaldauer und Zeitzone entfallen. | --- | --- | ---: | --- |
| `LeistungsStufen` | JSON-Liste | `[]` | Stufe, positive Leistung in W und Boolean-Schaltkontakt. |
| `ZeitKonstante` | Integer | `120` | PT1-Zeitkonstante in Sekunden. |
| `Boilerfuehler_PT1` | Integer | `0` | Temperaturvariable. |
| `BoilertemperaturGlaetten` | Boolean | `false` | PT1-Glaettung aktiv. |
| `Boilervolumen` | Integer | `300` | Volumen in Litern fuer die Zeitplanprognose. |
| `Zeitplan` | JSON-Liste | `[]` | Uhrzeit und Solltemperatur. |
| `Interval` | Integer | `5` | Regelintervall in Sekunden. |
| `IdleCounterMax` | Integer | `2` | Zyklen bis zum Idle-Status. |
| `TemperaturMaxAlter` | Integer | `30` | Maximal zulaessiges Messwertalter in Sekunden. |
| `Hysterese` | Float | `5.0` | Temperaturhysterese in K. |
| `LegionellenfunktionAktiv` | Boolean | `true` | Periodische Aufheizung aktiv. |
| `LegionellenIntervallTage` | Integer | `7` | Intervall seit erfolgreichem Abschluss. |
| `LegionellenHaltezeit` | Integer | `0` | Haltezeit auf Zieltemperatur in Sekunden. |
| `LegionellenMaximaldauer` | Integer | `86400` | Abbruchzeit in Sekunden. |
| `NachtstartStunde` | Integer | `22` | Fruehester Programmstart. |
| `NachtendeStunde` | Integer | `7` | Ende des Nachtfensters. |
| `DiagnosevariablenAnzeigen` | Boolean | `false` | Blendet zehn Diagnosevariablen ein. |
## Zustand `LoggingEin` aktiviert laufende Debug-Ausgaben. Der Formular-Knopf schreibt
unabhaengig davon genau einen aktuellen Statusdatensatz ins Debug-Log.
`Wassertemperatur_C` als Istwert, `Maximaltemperatur_C` als Sollwert, ## Variablen
`AktiveStufe`, `NachladenAktiv` und `LegionellenbetriebAktiv` als Status sowie
`Fuehlerfehler` und `Uebertemperatur` als Störungen.
## Offene Punkte Standardmaessig sichtbar sind nur die acht Betriebsvariablen `Aktiv`,
`Istleistung`, `Sollleistung`, `Stoerung`, `Stoertext`, `Boilertemperatur`,
`AktiveStufe` und `BezogeneEnergie`.
- Ablauf für „Wassererwärmer über Wärmepumpe betreiben“ separat festlegen. Mit `EinstellungenInVisu` werden `Mindesttemperatur`, `Maximaltemperatur` und
- Konkreten Temperaturadapter und die Fühleranbindung bestätigen. `Legionellentemperatur` sichtbar und geprueft bedienbar. Die Reihenfolge
`Mindesttemperatur < Maximaltemperatur <= Legionellentemperatur` ist zwingend.
Mit `DiagnosevariablenAnzeigen` werden diese zehn Variablen eingeblendet:
- `Leistungsquelle`
- `SollwertGueltig`
- `Verfuegbar`
- `AenderungMoeglich`
- `TemperaturGueltig`
- `NachladenAktiv`
- `LegionellenbetriebAktiv`
- `LegioCounter`
- `Idle`
- `IdleCounter`
## Managerkommunikation
Das Modul implementiert `VerbraucherSchnittstelle::ManagerdatenEmpfangen()`.
Der Transport erfolgt wie beim Manager ueber `IPS_RequestAction` mit JSON. Der
Verbraucher besitzt keine Manager-ID-Property. Er ermittelt nur Manager, in
deren `VerbraucherZuordnung` seine Instanz aktiv eingetragen ist.
Die Rueckmeldung erfolgt ereignisbasiert und zusaetzlich alle
`Meldeintervall` Sekunden. Eine Rueckmeldung nach empfangener Vorgabe wird kurz
verzoegert, damit keine synchrone Manager-Verbraucher-Endlosschleife entsteht.
## Tests
`WarmwasserReglerTest.php` prueft Leistungsstufen, PT1, Energieberechnung,
Zeitplan und thermische Prognose. `WarmwassererwaermerModulstrukturTest.php`
prueft Metadaten, Formular, 23 Properties, Sichtbarkeit, Schnittstellenvertrag
und Break-before-make-Schaltung.
## Migration
Die Feldzuordnung und die bewusst entfallenen Enelix-1-Kommunikationsvariablen
sind in [`docs/migration/Boiler-x-Stufig.md`](../../migration/Boiler-x-Stufig.md)
dokumentiert.
+192
View File
@@ -0,0 +1,192 @@
<?php
declare(strict_types=1);
namespace Belevo\EnelixEMS;
use DateTimeImmutable;
use InvalidArgumentException;
/**
* Reine Hilfsfunktionen des mehrstufigen Warmwassererwaermers.
*
* Die Klasse kennt keine IP-Symcon-Funktionen und ist dadurch unabhaengig
* vom Laufzeitsystem testbar.
*/
final class WarmwasserRegler
{
/**
* @return list<array{Stufe: int, Leistung_W: int, KontaktID: int}>
*/
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<array{Stufe: int, Leistung_W: int, KontaktID: int}> $stufen
* @return list<int>
*/
public static function leistungswerte(array $stufen): array
{
return array_merge([0], array_column($stufen, 'Leistung_W'));
}
/**
* @param list<array{Stufe: int, Leistung_W: int, KontaktID: int}> $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 pt1(float $bisher, float $eingang, int $deltaSekunden, int $zeitkonstante): float
{
if ($deltaSekunden <= 0) {
throw new InvalidArgumentException('Das Zeitintervall muss groesser als 0 sein.');
}
if ($zeitkonstante <= 0) {
throw new InvalidArgumentException('Die Zeitkonstante muss groesser als 0 sein.');
}
$alpha = $deltaSekunden / ($zeitkonstante + $deltaSekunden);
return $bisher + $alpha * ($eingang - $bisher);
}
public static function energieKWh(float $leistungW, int $dauerSekunden): float
{
if ($dauerSekunden < 0) {
throw new InvalidArgumentException('Die Dauer darf nicht negativ sein.');
}
return $leistungW * $dauerSekunden / 3600000.0;
}
public static function kannZielErreichen(
float $volumenLiter,
float $isttemperatur,
float $zieltemperatur,
int $verbleibendeSekunden,
int $maximalleistungW
): bool {
if ($zieltemperatur <= $isttemperatur) {
return true;
}
if ($volumenLiter <= 0 || $verbleibendeSekunden < 0 || $maximalleistungW <= 0) {
return false;
}
$erforderlicheWaermeJ = 4186.0 * $volumenLiter * ($zieltemperatur - $isttemperatur);
$verfuegbareWaermeJ = $maximalleistungW * $verbleibendeSekunden;
return $verfuegbareWaermeJ >= $erforderlicheWaermeJ;
}
/**
* @return array{Zeitpunkt: int, Solltemperatur: float}|null
*/
public static function naechsterZeitplaneintrag(string $json, DateTimeImmutable $jetzt): ?array
{
if (trim($json) === '') {
return null;
}
$werte = json_decode($json, true, 512, JSON_THROW_ON_ERROR);
if (!is_array($werte) || !self::istListe($werte)) {
throw new InvalidArgumentException('Zeitplan muss eine JSON-Liste sein.');
}
$naechster = null;
foreach ($werte as $eintrag) {
if (!is_array($eintrag)
|| !is_string($eintrag['Uhrzeit'] ?? null)
|| !is_numeric($eintrag['Solltemperatur'] ?? null)
|| !preg_match('/^(?:[01]\\d|2[0-3]):[0-5]\\d$/', $eintrag['Uhrzeit'])
) {
throw new InvalidArgumentException('Zeitplaneintraege benoetigen Uhrzeit HH:MM und Solltemperatur.');
}
[$stunde, $minute] = array_map('intval', explode(':', $eintrag['Uhrzeit']));
$zeitpunkt = $jetzt->setTime($stunde, $minute, 0);
if ($zeitpunkt <= $jetzt) {
$zeitpunkt = $zeitpunkt->modify('+1 day');
}
if ($naechster === null || $zeitpunkt < $naechster['Datum']) {
$naechster = [
'Datum' => $zeitpunkt,
'Solltemperatur' => (float) $eintrag['Solltemperatur'],
];
}
}
if ($naechster === null) {
return null;
}
return [
'Zeitpunkt' => $naechster['Datum']->getTimestamp(),
'Solltemperatur' => $naechster['Solltemperatur'],
];
}
/** @param array<mixed> $wert */
private static function istListe(array $wert): bool
{
return $wert === [] || array_keys($wert) === range(0, count($wert) - 1);
}
}
+17 -9
View File
@@ -8,18 +8,17 @@ use PHPUnit\Framework\TestCase;
final class DokumentationsstrukturTest extends TestCase final class DokumentationsstrukturTest extends TestCase
{ {
/** @return list<string> */ /** @return list<array{string}> */
public function modulReadmes(): array public function modulReadmes(): array
{ {
return [ return [
'Batterie', ['Batterie'],
'Wassererwaermer', ['Pufferspeicher'],
'Pufferspeicher', ['Verbraucher-1-Stufig'],
'Verbraucher-1-Stufig', ['Waermepumpe'],
'Waermepumpe', ['Ladestation-Stand-Alone'],
'Ladestation-Stand-Alone', ['Ladestation-Gateway'],
'Ladestation-Gateway', ['Easee-Gateway'],
'Easee-Gateway',
]; ];
} }
@@ -49,4 +48,13 @@ final class DokumentationsstrukturTest extends TestCase
self::assertStringContainsString('Status: Implementiert', $inhalt); self::assertStringContainsString('Status: Implementiert', $inhalt);
self::assertStringContainsString('Verteilalgorithmus', $inhalt); self::assertStringContainsString('Verteilalgorithmus', $inhalt);
} }
public function testWarmwassererwaermerDokumentiertDenImplementiertenStand(): void
{
$inhalt = file_get_contents(__DIR__ . '/../docs/module/Wassererwaermer/README.md');
self::assertNotFalse($inhalt);
self::assertStringContainsString('Status: implementiert', $inhalt);
self::assertStringContainsString('Properties', $inhalt);
self::assertStringContainsString('Managerkommunikation', $inhalt);
}
} }
+1 -1
View File
@@ -88,7 +88,7 @@ final class ManagerReglerTest extends TestCase
public function testNichtAenderbarerVerbraucherReserviertSeineIstleistung(): void public function testNichtAenderbarerVerbraucherReserviertSeineIstleistung(): void
{ {
$fest = $this->verbraucher(10, 0, [0, 3000], 3000, false); $fest = $this->verbraucher(10, 0, [0, 3000], 3000, false);
$steuerbar = $this->verbraucher(20, 1, [0, ['Von_W' => 0, 'Bis_W' => 4000]], 0); $steuerbar = $this->verbraucher(20, 1, [0, ['Von_W' => 1, 'Bis_W' => 4000]], 0);
$ergebnis = ManagerRegler::verteile(3000.0, 5000.0, [$fest, $steuerbar], 'PrioritaetPV'); $ergebnis = ManagerRegler::verteile(3000.0, 5000.0, [$fest, $steuerbar], 'PrioritaetPV');
+82
View File
@@ -0,0 +1,82 @@
<?php
declare(strict_types=1);
namespace Belevo\EnelixEMS\Tests;
use Belevo\EnelixEMS\WarmwasserRegler;
use DateTimeImmutable;
use DateTimeZone;
use InvalidArgumentException;
use PHPUnit\Framework\TestCase;
final class WarmwasserReglerTest extends TestCase
{
public function testLeistungsstufenWerdenValidiertUndAufsteigendSortiert(): void
{
$stufen = WarmwasserRegler::dekodiereLeistungsstufen(json_encode([
['Stufe' => 2, 'Leistung' => 6000, 'Schaltkontakt_Stufe' => 102],
['Stufe' => 1, 'Leistung' => 3000, 'Schaltkontakt_Stufe' => 101],
], JSON_THROW_ON_ERROR));
self::assertSame(
[
['Stufe' => 1, 'Leistung_W' => 3000, 'KontaktID' => 101],
['Stufe' => 2, 'Leistung_W' => 6000, 'KontaktID' => 102],
],
$stufen
);
self::assertSame([0, 3000, 6000], WarmwasserRegler::leistungswerte($stufen));
self::assertSame(2, WarmwasserRegler::stufeFuerLeistung($stufen, 6000));
self::assertSame(0, WarmwasserRegler::stufeFuerLeistung($stufen, 0));
}
public function testDoppelteKontakteWerdenAbgewiesen(): void
{
$this->expectException(InvalidArgumentException::class);
WarmwasserRegler::dekodiereLeistungsstufen(json_encode([
['Stufe' => 1, 'Leistung' => 3000, 'Schaltkontakt_Stufe' => 101],
['Stufe' => 2, 'Leistung' => 6000, 'Schaltkontakt_Stufe' => 101],
], JSON_THROW_ON_ERROR));
}
public function testNichtKonfigurierteSollleistungWirdAbgewiesen(): void
{
$this->expectException(InvalidArgumentException::class);
WarmwasserRegler::stufeFuerLeistung([
['Stufe' => 1, 'Leistung_W' => 3000, 'KontaktID' => 101],
], 1500);
}
public function testPt1VerwendetRegelintervallUndZeitkonstante(): void
{
self::assertEqualsWithDelta(20.4, WarmwasserRegler::pt1(20.0, 30.0, 5, 120), 0.001);
}
public function testEnergieWirdInKWhBerechnet(): void
{
self::assertEqualsWithDelta(3.0, WarmwasserRegler::energieKWh(3000.0, 3600), 0.000001);
}
public function testThermischePrognose(): void
{
self::assertTrue(WarmwasserRegler::kannZielErreichen(300.0, 50.0, 60.0, 7200, 3000));
self::assertFalse(WarmwasserRegler::kannZielErreichen(300.0, 20.0, 60.0, 3600, 3000));
}
public function testNaechsterZeitplaneintragIstHeuteOderMorgen(): void
{
$jetzt = new DateTimeImmutable('2026-09-16 10:00:00', new DateTimeZone('Europe/Zurich'));
$eintrag = WarmwasserRegler::naechsterZeitplaneintrag(json_encode([
['Uhrzeit' => '08:00', 'Solltemperatur' => 50],
['Uhrzeit' => '11:00', 'Solltemperatur' => 55],
], JSON_THROW_ON_ERROR), $jetzt);
self::assertNotNull($eintrag);
self::assertSame(
(new DateTimeImmutable('2026-09-16 11:00:00', new DateTimeZone('Europe/Zurich')))->getTimestamp(),
$eintrag['Zeitpunkt']
);
self::assertSame(55.0, $eintrag['Solltemperatur']);
}
}
@@ -0,0 +1,89 @@
<?php
declare(strict_types=1);
namespace Belevo\EnelixEMS\Tests;
use PHPUnit\Framework\TestCase;
final class WarmwassererwaermerModulstrukturTest extends TestCase
{
private const MODULPFAD = __DIR__ . '/../Warmwassererwaermer/';
public function testModulIstVollstaendigUndMetadatenSindGueltig(): void
{
foreach (['module.json', 'form.json', 'module.php'] as $datei) {
self::assertFileExists(self::MODULPFAD . $datei);
}
$metadaten = json_decode(
(string) file_get_contents(self::MODULPFAD . 'module.json'),
true,
512,
JSON_THROW_ON_ERROR
);
self::assertSame('VerbraucherWarmwassererwaermer', $metadaten['name']);
self::assertSame(3, $metadaten['type']);
self::assertMatchesRegularExpression('/^\{[0-9A-F-]{36}\}$/', $metadaten['id']);
}
public function testAlle23PropertiesSindRegistriertUndImFormularDokumentiert(): void
{
$basis = (string) file_get_contents(__DIR__ . '/../libs/VerbraucherBasisTrait.php');
$modul = (string) file_get_contents(self::MODULPFAD . 'module.php');
preg_match_all('/RegisterProperty(?:Integer|Float|Boolean|String)\(\'([^\']+)\'/', $basis . $modul, $treffer);
$properties = array_values(array_unique($treffer[1]));
self::assertCount(23, $properties);
$formular = (string) file_get_contents(self::MODULPFAD . 'form.json');
foreach ($properties as $property) {
self::assertStringContainsString('"name": "' . $property . '"', $formular, $property);
}
}
public function testModulVerwendetAusschliesslichDenVertrag30(): void
{
$inhalt = (string) file_get_contents(self::MODULPFAD . 'module.php');
self::assertStringContainsString('implements VerbraucherSchnittstelle', $inhalt);
self::assertStringContainsString('ManagerdatenEmpfangen(array $daten)', $inhalt);
self::assertStringContainsString('Nachrichtenvertrag::pruefeManagerdaten', $inhalt);
self::assertStringContainsString('Nachrichtenvertrag::pruefeVerbraucherdaten', $inhalt);
self::assertStringNotContainsString("RegisterVariableString('PowerSteps'", $inhalt);
self::assertStringNotContainsString("RegisterPropertyInteger('ManagerID'", $inhalt);
}
public function testZehnDiagnosevariablenSindOptionalUndLoggingIstGetrennt(): void
{
$inhalt = (string) file_get_contents(self::MODULPFAD . 'module.php');
preg_match('/private const DIAGNOSE_VARIABLEN = \[(.*?)\];/s', $inhalt, $treffer);
self::assertArrayHasKey(1, $treffer);
preg_match_all('/\'([^\']+)\'/', $treffer[1], $variablen);
self::assertCount(10, $variablen[1]);
self::assertStringContainsString("ReadPropertyBoolean('DiagnosevariablenAnzeigen')", $inhalt);
self::assertStringContainsString("ReadPropertyBoolean('LoggingEin')", $inhalt);
self::assertStringContainsString("case 'LogStatus':", $inhalt);
}
public function testTemperatursollwerteSindGeprueftBedienbar(): void
{
$inhalt = (string) file_get_contents(self::MODULPFAD . 'module.php');
foreach (['Mindesttemperatur', 'Maximaltemperatur', 'Legionellentemperatur'] as $ident) {
self::assertStringContainsString("EnableAction('" . $ident . "')", $inhalt);
}
self::assertStringContainsString('setzeTemperaturSollwert', $inhalt);
self::assertStringContainsString('Mindesttemperatur < Maximaltemperatur', $inhalt);
}
public function testSchaltfolgeIstBreakBeforeMake(): void
{
$inhalt = (string) file_get_contents(self::MODULPFAD . 'module.php');
$aus = strpos($inhalt, "RequestAction(\$stufe['KontaktID'], false)");
$ein = strpos($inhalt, "RequestAction(\$stufe['KontaktID'], true)");
self::assertIsInt($aus);
self::assertIsInt($ein);
self::assertLessThan($ein, $aus);
}
}