This commit is contained in:
@@ -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)
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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 |
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user