Files
Enelix-EMS/Warmwassererwaermer/module.php
T
2026-10-06 13:45:54 +00:00

1065 lines
44 KiB
PHP

<?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 = [
'Istleistung',
'Leistungsquelle',
'Sollleistung',
'SollwertGueltig',
'Verfuegbar',
'AenderungMoeglich',
'Stoerung',
'Stoertext',
'AktiveStufe',
'BezogeneEnergie',
'TemperaturGueltig',
'NachladenAktiv',
'LegionellenbetriebAktiv',
'LegioCounter',
];
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('LastwechselSperrzeit', 5);
$this->RegisterPropertyInteger('TemperaturMaxAlter', 30);
$this->RegisterPropertyFloat('TemperaturUntergrenze', 0.0);
$this->RegisterPropertyFloat('TemperaturObergrenze', 100.0);
$this->RegisterPropertyFloat('Hysterese', 5.0);
$this->RegisterPropertyBoolean('LegionellenfunktionAktiv', true);
$this->RegisterPropertyInteger('LegionellenMinimalintervallTage', 4);
$this->RegisterPropertyInteger('LegionellenMaximalintervallTage', 7);
$this->RegisterPropertyBoolean('DiagnosevariablenAnzeigen', false);
$this->RegisterVariableFloat('Boilertemperatur', 'Boilertemperatur', '~Temperature', 100);
$this->RegisterVariableFloat('Mindesttemperatur', 'Mindesttemperatur', '~Temperature', 130);
$this->RegisterVariableFloat('Maximaltemperatur', 'Maximaltemperatur', '~Temperature', 140);
$this->EnableAction('Mindesttemperatur');
$this->EnableAction('Maximaltemperatur');
$this->RegisterAttributeBoolean('InitialwerteGesetzt', false);
$this->RegisterAttributeInteger('RegistrierteFuehlerID', 0);
$this->RegisterAttributeInteger('LetzteBerechnung', 0);
$this->RegisterAttributeInteger('LetzteTemperaturberechnung', 0);
$this->RegisterAttributeInteger('LetzteVorgabeZeit', 0);
$this->RegisterAttributeInteger('LetzterLastwechsel', 0);
$this->RegisterAttributeInteger('LetzteManagerID', 0);
$this->RegisterAttributeInteger('LetzterLegionellenabschluss', 0);
$this->RegisterAttributeFloat('Glaettungswert', 0.0);
$this->RegisterAttributeBoolean('GlaettungInitialisiert', false);
$this->RegisterAttributeString('Leistungsangebot', '[]');
$this->RegisterAttributeString('Betriebsart', Nachrichtenvertrag::BETRIEBSART_PV);
$this->RegisterAttributeString('Schaltfehler', '');
$this->RegisterAttributeFloat('ZustandIstleistung', 0.0);
$this->RegisterAttributeInteger('ZustandSollleistung', 0);
$this->RegisterAttributeBoolean('ZustandStoerung', false);
$this->RegisterAttributeString('ZustandStoertext', '');
$this->RegisterAttributeInteger('ZustandAktiveStufe', 0);
$this->RegisterAttributeFloat('ZustandBezogeneEnergie', 0.0);
$this->RegisterAttributeFloat('ZustandLegionellentemperatur', 65.0);
$this->RegisterAttributeBoolean('ZustandSollwertGueltig', false);
$this->RegisterAttributeBoolean('ZustandVerfuegbar', false);
$this->RegisterAttributeBoolean('ZustandAenderungMoeglich', false);
$this->RegisterAttributeBoolean('ZustandTemperaturGueltig', false);
$this->RegisterAttributeBoolean('ZustandNachladenAktiv', false);
$this->RegisterAttributeBoolean('MaximaltemperaturErreicht', false);
$this->RegisterAttributeBoolean('ZustandLegionellenbetriebAktiv', false);
$this->RegisterAttributeInteger('ZustandLegioCounter', 0);
$this->RegisterTimer(
'LastwechselFreigabe',
0,
"IPS_RequestAction(\$_IPS['TARGET'], 'LastwechselFreigabe', false);"
);
$this->RegisterTimer(
'Meldezyklus',
0,
"IPS_RequestAction(\$_IPS['TARGET'], 'Melden', false);"
);
$this->RegisterTimer(
'RueckmeldungVerzoegert',
0,
"IPS_RequestAction(\$_IPS['TARGET'], 'Melden', true);"
);
}
public function ApplyChanges(): void
{
parent::ApplyChanges();
$this->initialisiereTemperaturen();
$this->aktualisiereVariablen();
$this->bereinigeUngueltigenSollwert();
try {
$this->pruefeKonfiguration();
} catch (Throwable $fehler) {
$this->SetTimerInterval('LastwechselFreigabe', 0);
$this->SetTimerInterval('Meldezyklus', 0);
$this->SetTimerInterval('RueckmeldungVerzoegert', 0);
$this->setzeZustand('Verfuegbar', false);
$this->setzeZustand('AenderungMoeglich', false);
$this->SetStatus(self::STATUS_KONFIGURATION_UNGUELTIG);
$this->protokolliere('Konfiguration', $fehler->getMessage());
return;
}
$this->registriereFuehlermeldung();
$this->SetTimerInterval('LastwechselFreigabe', 0);
$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 'LastwechselFreigabe':
$this->SetTimerInterval('LastwechselFreigabe', 0);
$this->regelzyklus(true);
return;
case 'Melden':
if ((bool) $wert) {
$this->SetTimerInterval('RueckmeldungVerzoegert', 0);
}
$this->regelzyklus(false);
$this->sendeVerbraucherdaten();
return;
case 'ManagerdatenEmpfangen':
if (!is_string($wert)) {
throw new InvalidArgumentException('Managerdaten muessen als JSON uebergeben werden.');
}
$daten = json_decode($wert, true, 512, JSON_THROW_ON_ERROR);
if (!is_array($daten)) {
throw new InvalidArgumentException('Managerdaten muessen ein JSON-Objekt sein.');
}
$this->ManagerdatenEmpfangen($daten);
return;
}
throw new InvalidArgumentException('Unbekannte Aktion: ' . $ident);
}
/** @param array<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->WriteAttributeString('Betriebsart', $daten['Betriebsart']);
$this->regelzyklus(false);
$angebot = $this->leseLeistungsangebot();
$sollleistung = $daten['Sollleistung_W'];
if ($sollleistung === null) {
if ((bool) $this->leseZustand('SollwertGueltig')
&& !in_array((int) $this->leseZustand('Sollleistung'), $angebot, true)
) {
$this->verwerfeSollwert();
$this->regelzyklus(false);
}
$this->SetTimerInterval('RueckmeldungVerzoegert', 100);
return;
}
if (!in_array($sollleistung, $angebot, true)) {
throw new InvalidArgumentException('Sollleistung_W liegt nicht im aktuell gemeldeten Leistungsangebot.');
}
$this->WriteAttributeInteger('LetzteManagerID', $managerID);
$this->WriteAttributeInteger('LetzteVorgabeZeit', time());
$this->setzeZustand('Sollleistung', $sollleistung);
$this->setzeZustand('SollwertGueltig', true);
$this->regelzyklus(false);
// 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);
$restzeit = $this->aktualisiereLastwechselstatus($jetzt);
[$angebot, $aenderungMoeglich, $verfuegbar, $zwangsleistung] =
$this->bestimmeLeistungsangebot($temperaturGueltig, $jetzt);
$aktuelleLeistung = (int) round((float) $this->leseZustand('Istleistung'));
$lastwechselGesperrt = $restzeit > 0 && $verfuegbar && $zwangsleistung !== 0;
if ($lastwechselGesperrt) {
$angebot = [$aktuelleLeistung];
$aenderungMoeglich = false;
}
$this->WriteAttributeString('Leistungsangebot', json_encode($angebot, JSON_THROW_ON_ERROR));
$this->setzeZustand('Verfuegbar', $verfuegbar);
$this->setzeZustand('AenderungMoeglich', $aenderungMoeglich);
$letzteVorgabe = $this->ReadAttributeInteger('LetzteVorgabeZeit');
if ($letzteVorgabe <= 0
|| $jetzt - $letzteVorgabe > $this->ReadPropertyInteger('VorgabeTimeout')
) {
$this->verwerfeSollwert();
}
$ziel = 0;
if ($lastwechselGesperrt) {
$ziel = $aktuelleLeistung;
} elseif ($verfuegbar) {
if ($zwangsleistung !== null) {
$ziel = $zwangsleistung;
} elseif ((bool) $this->leseZustand('SollwertGueltig')) {
$ziel = (int) $this->leseZustand('Sollleistung');
if (!in_array($ziel, $angebot, true)) {
$this->verwerfeSollwert();
$ziel = 0;
}
}
}
$lastwechselErfolgt = $this->schalteLeistung($ziel, $jetzt);
if ($lastwechselErfolgt) {
$this->WriteAttributeString('Leistungsangebot', json_encode([$ziel], JSON_THROW_ON_ERROR));
$this->setzeZustand('AenderungMoeglich', false);
}
$this->setzeZustand('Sollleistung', $ziel);
$this->aktualisiereStoerungen($temperaturGueltig);
if ($meldungPlanen) {
$this->SetTimerInterval('RueckmeldungVerzoegert', 100);
}
} catch (Throwable $fehler) {
$this->WriteAttributeString('Schaltfehler', $fehler->getMessage());
$this->setzeZustand('Verfuegbar', false);
$this->setzeZustand('AenderungMoeglich', false);
$this->setzeZustand('Stoerung', true);
$this->setzeZustand('Stoertext', $fehler->getMessage());
$this->SetStatus(self::STATUS_SCHALTFEHLER);
$this->protokolliere('Regelzyklus', $fehler->getMessage());
}
}
private function aktualisiereTemperatur(): bool
{
$fuehlerID = $this->ReadPropertyInteger('Boilerfuehler_PT1');
if ($fuehlerID <= 0 || !IPS_VariableExists($fuehlerID)) {
$this->setzeZustand('TemperaturGueltig', false);
return false;
}
$variable = IPS_GetVariable($fuehlerID);
if (time() - (int) $variable['VariableUpdated'] > $this->ReadPropertyInteger('TemperaturMaxAlter')) {
$this->setzeZustand('TemperaturGueltig', false);
return false;
}
$rohwert = GetValue($fuehlerID);
if (!is_int($rohwert) && !is_float($rohwert)) {
$this->setzeZustand('TemperaturGueltig', false);
return false;
}
$temperatur = (float) $rohwert;
$jetzt = time();
$letzteTemperaturberechnung = $this->ReadAttributeInteger('LetzteTemperaturberechnung');
$this->WriteAttributeInteger('LetzteTemperaturberechnung', $jetzt);
if ($this->ReadPropertyBoolean('BoilertemperaturGlaetten')) {
if (!$this->ReadAttributeBoolean('GlaettungInitialisiert')) {
$this->WriteAttributeFloat('Glaettungswert', $temperatur);
$this->WriteAttributeBoolean('GlaettungInitialisiert', true);
} elseif ($letzteTemperaturberechnung > 0 && $jetzt > $letzteTemperaturberechnung) {
$temperatur = WarmwasserRegler::pt1(
$this->ReadAttributeFloat('Glaettungswert'),
$temperatur,
$jetzt - $letzteTemperaturberechnung,
$this->ReadPropertyInteger('ZeitKonstante')
);
$this->WriteAttributeFloat('Glaettungswert', $temperatur);
} else {
$temperatur = $this->ReadAttributeFloat('Glaettungswert');
}
} else {
$this->WriteAttributeBoolean('GlaettungInitialisiert', false);
}
$this->SetValue('Boilertemperatur', $temperatur);
$this->setzeZustand('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->setzeZustand('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');
[$minimum, $maximum] = WarmwasserRegler::legionellenTemperaturgrenzen(
$this->ReadPropertyBoolean('LegionellenfunktionAktiv'),
max(0, $jetzt - $this->ReadAttributeInteger('LetzterLegionellenabschluss')),
$this->ReadPropertyInteger('LegionellenMinimalintervallTage'),
$this->ReadPropertyInteger('LegionellenMaximalintervallTage'),
$minimum,
$maximum,
(float) $this->ReadAttributeFloat('ZustandLegionellentemperatur')
);
$hysterese = $this->ReadPropertyFloat('Hysterese');
$naechster = WarmwasserRegler::naechsterZeitplaneintrag(
$this->ReadPropertyString('Zeitplan'),
new DateTimeImmutable('now')
);
if ($naechster !== null
&& $temperatur < $naechster['Solltemperatur']
&& !WarmwasserRegler::kannZielErreichen(
(float) $this->ReadPropertyInteger('Boilervolumen'),
$temperatur,
$naechster['Solltemperatur'],
max(0, $naechster['Zeitpunkt'] - $jetzt),
$maximalleistung
)
) {
$minimum = $naechster['Solltemperatur'];
}
$stufeAktiv = (float) $this->leseZustand('Istleistung') > 0.0;
$nachladen = $temperatur < $minimum
|| ($temperatur < $minimum + $hysterese && $stufeAktiv);
$this->setzeZustand('NachladenAktiv', $nachladen);
$peakbetrieb = $this->ReadAttributeString('Betriebsart')
=== Nachrichtenvertrag::BETRIEBSART_PEAK;
$angebot = WarmwasserRegler::leistungsangebot(
$peakbetrieb,
$temperatur,
$minimum,
$maximum,
$hysterese,
$stufeAktiv,
$alleLeistungen
);
if ($angebot === [0]) {
return [$angebot, false, true, 0];
}
if ($angebot === [$maximalleistung]) {
return [$angebot, false, true, $maximalleistung];
}
return [$angebot, true, true, null];
}
private function aktualisiereLegionellenstatus(bool $temperaturGueltig, int $jetzt): void
{
$aktiviert = $this->ReadPropertyBoolean('LegionellenfunktionAktiv');
$letzterAbschluss = $this->ReadAttributeInteger('LetzterLegionellenabschluss');
$sekundenSeitAbschluss = max(0, $jetzt - $letzterAbschluss);
if ($aktiviert && $temperaturGueltig
&& (float) $this->GetValue('Boilertemperatur')
>= $this->ReadAttributeFloat('ZustandLegionellentemperatur')
) {
$this->WriteAttributeInteger('LetzterLegionellenabschluss', $jetzt);
$sekundenSeitAbschluss = 0;
}
$fruehestensFaellig = $aktiviert
&& $sekundenSeitAbschluss >= $this->ReadPropertyInteger('LegionellenMinimalintervallTage') * 86400;
$this->setzeZustand('LegioCounter', $sekundenSeitAbschluss);
$this->setzeZustand('LegionellenbetriebAktiv', $fruehestensFaellig);
}
private function schalteLeistung(int $zielLeistung, int $jetzt): bool
{
$stufen = WarmwasserRegler::dekodiereLeistungsstufen($this->ReadPropertyString('LeistungsStufen'));
$zielStufe = WarmwasserRegler::stufeFuerLeistung($stufen, $zielLeistung);
$bisherigeLeistung = (int) round((float) $this->leseZustand('Istleistung'));
if ($zielLeistung === $bisherigeLeistung && $this->kontakteEntsprechenZiel($stufen, $zielLeistung)) {
$this->WriteAttributeString('Schaltfehler', '');
return false;
}
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->setzeZustand('Istleistung', 0.0);
$this->setzeZustand('AktiveStufe', 0);
$this->WriteAttributeInteger('LetzterLastwechsel', $jetzt);
$this->aktualisiereLastwechselstatus($jetzt);
throw new RuntimeException('Leistungsstufe konnte nicht sicher geschaltet werden: ' . $fehler->getMessage());
}
$this->WriteAttributeString('Schaltfehler', '');
$this->setzeZustand('Istleistung', (float) $zielLeistung);
$this->setzeZustand('Leistungsquelle', Nachrichtenvertrag::LEISTUNGSQUELLE_BERECHNET);
$this->setzeZustand('AktiveStufe', $zielStufe);
$this->WriteAttributeInteger('LetzterLastwechsel', $jetzt);
$this->aktualisiereLastwechselstatus($jetzt);
$this->protokolliere('Leistungsstufe', ['Leistung_W' => $zielLeistung, 'Stufe' => $zielStufe]);
return true;
}
/**
* @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 aktualisiereLastwechselstatus(int $jetzt): int
{
$restzeit = WarmwasserRegler::restzeitBisLastwechsel(
$this->ReadAttributeInteger('LetzterLastwechsel'),
$jetzt,
$this->ReadPropertyInteger('LastwechselSperrzeit')
);
$this->SetTimerInterval('LastwechselFreigabe', $restzeit > 0 ? $restzeit * 1000 : 0);
return $restzeit;
}
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->leseZustand('Istleistung'),
$jetzt - $letzteBerechnung
);
$this->setzeZustand('BezogeneEnergie', (float) $this->leseZustand('BezogeneEnergie') + $zuwachs);
}
private function aktualisiereStoerungen(bool $temperaturGueltig): void
{
$stoerungen = [];
if (!$temperaturGueltig) {
$stoerungen[] = 'Boilertemperatur fehlt oder ist veraltet.';
}
$untertemperatur = $this->istUntertemperatur($temperaturGueltig);
if ($untertemperatur) {
$stoerungen[] = 'Untertemperatur erkannt.';
}
$uebertemperatur = $this->istUebertemperatur($temperaturGueltig);
if ($uebertemperatur) {
$stoerungen[] = 'Uebertemperatur erkannt.';
}
if ($this->ReadAttributeString('Schaltfehler') !== '') {
$stoerungen[] = $this->ReadAttributeString('Schaltfehler');
}
$this->setzeZustand('Stoerung', $stoerungen !== []);
$this->setzeZustand('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 istUntertemperatur(bool $temperaturGueltig): bool
{
return $temperaturGueltig
&& (float) $this->GetValue('Boilertemperatur')
< $this->ReadPropertyFloat('TemperaturUntergrenze');
}
private function istUebertemperatur(bool $temperaturGueltig): bool
{
return $temperaturGueltig
&& (float) $this->GetValue('Boilertemperatur')
> $this->ReadPropertyFloat('TemperaturObergrenze');
}
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->leseZustand('TemperaturGueltig');
$schaltfehler = $this->ReadAttributeString('Schaltfehler');
$untertemperatur = $this->istUntertemperatur($temperaturGueltig);
$uebertemperatur = $this->istUebertemperatur($temperaturGueltig);
return [
'Kopf' => [
'Version' => Nachrichtenvertrag::VERSION,
'AbsenderID' => $this->InstanceID,
'EmpfaengerID' => $managerID,
'Zeitpunkt' => time(),
],
'Betriebsart' => $this->ReadAttributeString('Betriebsart'),
'PrioritaetPV' => $this->ReadPropertyInteger('PrioritaetPV'),
'PrioritaetPeak' => $this->ReadPropertyInteger('PrioritaetPeak'),
'Leistungswerte_W' => $this->leseLeistungsangebot(),
'AenderungMoeglich' => (bool) $this->leseZustand('AenderungMoeglich'),
'Verfuegbar' => (bool) $this->leseZustand('Verfuegbar'),
'Istleistung_W' => (float) $this->leseZustand('Istleistung'),
'Leistungsquelle' => Nachrichtenvertrag::LEISTUNGSQUELLE_BERECHNET,
'Zustand' => [
[
'Kennung' => 'Sollleistung_W',
'Art' => 'Sollwert',
'Wert' => (bool) $this->leseZustand('SollwertGueltig')
? (int) $this->leseZustand('Sollleistung')
: null,
'Einheit' => 'W',
],
[
'Kennung' => '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->leseZustand('AktiveStufe'),
'Einheit' => '',
],
[
'Kennung' => 'NachladenAktiv',
'Art' => 'Status',
'Wert' => (bool) $this->leseZustand('NachladenAktiv'),
'Einheit' => '',
],
[
'Kennung' => 'LegionellenbetriebAktiv',
'Art' => 'Status',
'Wert' => (bool) $this->leseZustand('LegionellenbetriebAktiv'),
'Einheit' => '',
],
[
'Kennung' => 'Fuehlerfehler',
'Art' => 'Stoerung',
'Wert' => !$temperaturGueltig,
'Einheit' => '',
'Text' => $temperaturGueltig ? '' : 'Boilertemperatur fehlt oder ist veraltet.',
],
[
'Kennung' => 'Untertemperatur',
'Art' => 'Stoerung',
'Wert' => $untertemperatur,
'Einheit' => '',
'Text' => $untertemperatur ? 'Untertemperatur erkannt.' : '',
],
[
'Kennung' => 'Uebertemperatur',
'Art' => 'Stoerung',
'Wert' => $uebertemperatur,
'Einheit' => '',
'Text' => $uebertemperatur ? 'Uebertemperatur erkannt.' : '',
],
[
'Kennung' => 'Schaltfehler',
'Art' => 'Stoerung',
'Wert' => $schaltfehler !== '',
'Einheit' => '',
'Text' => $schaltfehler,
],
],
];
}
/** @return list<int> */
private function zugeordneteManagerIDs(): array
{
$ergebnis = [];
foreach (IPS_GetInstanceListByModuleID(self::MANAGER_MODULE_ID) as $managerID) {
try {
$automatisch = (bool) IPS_GetProperty($managerID, 'AutomatischeSuche');
$property = $automatisch
? 'AutomatischeVerbraucherZuordnung'
: 'VerbraucherZuordnung';
$zuordnung = $this->leseManagerZuordnung($managerID, $property);
if ($automatisch && $zuordnung === []) {
$zuordnung = $this->leseManagerZuordnung($managerID, 'VerbraucherZuordnung');
}
} catch (Throwable $fehler) {
continue;
}
foreach ($zuordnung as $eintrag) {
if (!is_array($eintrag) || ($eintrag['Aktiv'] ?? false) !== true) {
continue;
}
$instanzID = $eintrag['InstanzID'] ?? $eintrag['Verbraucher'] ?? null;
if ($instanzID === $this->InstanceID) {
$ergebnis[] = (int) $managerID;
break;
}
}
}
return array_values(array_unique($ergebnis));
}
/** @return array<mixed> */
private function leseManagerZuordnung(int $managerID, string $property): array
{
$zuordnung = json_decode(
(string) IPS_GetProperty($managerID, $property),
true,
512,
JSON_THROW_ON_ERROR
);
return is_array($zuordnung) ? $zuordnung : [];
}
private function pruefeKonfiguration(): void
{
foreach (['PrioritaetPV', 'PrioritaetPeak'] as $property) {
if ($this->ReadPropertyInteger($property) < 0) {
throw new InvalidArgumentException($property . ' muss mindestens 0 sein.');
}
}
foreach ([
'Meldeintervall', 'VorgabeTimeout', 'LastwechselSperrzeit', 'TemperaturMaxAlter',
'ZeitKonstante', 'Boilervolumen', 'LegionellenMinimalintervallTage',
'LegionellenMaximalintervallTage',
] as $property) {
if ($this->ReadPropertyInteger($property) <= 0) {
throw new InvalidArgumentException($property . ' muss groesser als 0 sein.');
}
}
if ($this->ReadPropertyInteger('LegionellenMinimalintervallTage')
> $this->ReadPropertyInteger('LegionellenMaximalintervallTage')
) {
throw new InvalidArgumentException(
'Das Legionellen-Minimalintervall darf nicht groesser als das Maximalintervall sein.'
);
}
if ($this->ReadPropertyFloat('Hysterese') <= 0) {
throw new InvalidArgumentException('Hysterese muss groesser als 0 sein.');
}
if ($this->ReadPropertyFloat('TemperaturUntergrenze')
>= $this->ReadPropertyFloat('TemperaturObergrenze')
) {
throw new InvalidArgumentException(
'Die Temperaturuntergrenze muss kleiner als die Temperaturobergrenze sein.'
);
}
$minimum = (float) $this->GetValue('Mindesttemperatur');
$maximum = (float) $this->GetValue('Maximaltemperatur');
$legionellen = (float) $this->ReadAttributeFloat('ZustandLegionellentemperatur');
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->WriteAttributeFloat('ZustandLegionellentemperatur', 65.0);
$this->setzeZustand('Leistungsquelle', Nachrichtenvertrag::LEISTUNGSQUELLE_BERECHNET);
$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 ($ident === 'Legionellentemperatur'
&& !$this->ReadPropertyBoolean('LegionellenfunktionAktiv')
) {
throw new RuntimeException('Die Legionellenfunktion ist nicht aktiviert.');
}
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' => $this->ReadAttributeFloat('ZustandLegionellentemperatur'),
];
$werte[$ident] = $temperatur;
if ($werte['Mindesttemperatur'] >= $werte['Maximaltemperatur']
|| $werte['Maximaltemperatur'] > $werte['Legionellentemperatur']
) {
throw new InvalidArgumentException(
'Temperaturen muessen Mindesttemperatur < Maximaltemperatur <= Legionellentemperatur erfuellen.'
);
}
if ($ident === 'Legionellentemperatur') {
$this->WriteAttributeFloat('ZustandLegionellentemperatur', $temperatur);
$variablenID = @IPS_GetObjectIDByIdent($ident, $this->InstanceID);
if (is_int($variablenID) && $variablenID > 0 && IPS_VariableExists($variablenID)) {
SetValue($variablenID, $temperatur);
}
} else {
$this->SetValue($ident, $temperatur);
}
$this->regelzyklus(true);
}
private function aktualisiereVariablen(): void
{
foreach (['Idle', 'IdleCounter'] as $ident) {
$variablenID = @IPS_GetObjectIDByIdent($ident, $this->InstanceID);
if (is_int($variablenID) && $variablenID > 0 && IPS_VariableExists($variablenID)) {
$this->UnregisterVariable($ident);
}
}
foreach (self::DIAGNOSE_VARIABLEN as $ident) {
if ($ident === 'Leistungsquelle') {
continue;
}
$variablenID = @IPS_GetObjectIDByIdent($ident, $this->InstanceID);
if (is_int($variablenID) && $variablenID > 0 && IPS_VariableExists($variablenID)) {
$this->setzeZustand($ident, GetValue($variablenID));
}
}
if ($this->ReadPropertyBoolean('DiagnosevariablenAnzeigen')) {
$this->registriereVerbraucherDiagnose();
$this->RegisterVariableInteger('AktiveStufe', 'Aktive Stufe', '', 110);
$this->RegisterVariableFloat('BezogeneEnergie', 'Bezogene Energie', '~Electricity', 120);
$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);
foreach (self::DIAGNOSE_VARIABLEN as $ident) {
$wert = $ident === 'Leistungsquelle'
? Nachrichtenvertrag::LEISTUNGSQUELLE_BERECHNET
: $this->leseZustand($ident);
$this->setzeZustand($ident, $wert);
}
} else {
foreach (self::DIAGNOSE_VARIABLEN as $ident) {
$variablenID = @IPS_GetObjectIDByIdent($ident, $this->InstanceID);
if (is_int($variablenID) && $variablenID > 0 && IPS_VariableExists($variablenID)) {
$this->UnregisterVariable($ident);
}
}
}
$einstellungen = $this->ReadPropertyBoolean('EinstellungenInVisu');
foreach (['Mindesttemperatur', 'Maximaltemperatur'] as $ident) {
IPS_SetHidden($this->GetIDForIdent($ident), !$einstellungen);
if ($einstellungen) {
$this->EnableAction($ident);
} else {
$this->DisableAction($ident);
}
}
$legionellenID = @IPS_GetObjectIDByIdent('Legionellentemperatur', $this->InstanceID);
if (is_int($legionellenID) && $legionellenID > 0 && IPS_VariableExists($legionellenID)) {
$this->WriteAttributeFloat('ZustandLegionellentemperatur', (float) GetValue($legionellenID));
}
if ($this->ReadPropertyBoolean('LegionellenfunktionAktiv')) {
$this->RegisterVariableFloat('Legionellentemperatur', 'Legionellentemperatur', '~Temperature', 150);
$legionellenID = $this->GetIDForIdent('Legionellentemperatur');
SetValue($legionellenID, $this->ReadAttributeFloat('ZustandLegionellentemperatur'));
IPS_SetHidden($legionellenID, !$einstellungen);
if ($einstellungen) {
$this->EnableAction('Legionellentemperatur');
} else {
$this->DisableAction('Legionellentemperatur');
}
} elseif (is_int($legionellenID) && $legionellenID > 0 && IPS_VariableExists($legionellenID)) {
$this->UnregisterVariable('Legionellentemperatur');
}
}
/** @param bool|int|float|string $wert */
private function setzeZustand(string $ident, $wert): void
{
$booleanAttribute = [
'SollwertGueltig' => 'ZustandSollwertGueltig',
'Verfuegbar' => 'ZustandVerfuegbar',
'AenderungMoeglich' => 'ZustandAenderungMoeglich',
'Stoerung' => 'ZustandStoerung',
'TemperaturGueltig' => 'ZustandTemperaturGueltig',
'NachladenAktiv' => 'ZustandNachladenAktiv',
'LegionellenbetriebAktiv' => 'ZustandLegionellenbetriebAktiv',
];
$integerAttribute = [
'Sollleistung' => 'ZustandSollleistung',
'AktiveStufe' => 'ZustandAktiveStufe',
'LegioCounter' => 'ZustandLegioCounter',
];
$floatAttribute = [
'Istleistung' => 'ZustandIstleistung',
'BezogeneEnergie' => 'ZustandBezogeneEnergie',
];
$stringAttribute = [
'Stoertext' => 'ZustandStoertext',
];
if (isset($booleanAttribute[$ident])) {
$wert = (bool) $wert;
$this->WriteAttributeBoolean($booleanAttribute[$ident], $wert);
} elseif (isset($integerAttribute[$ident])) {
$wert = (int) $wert;
$this->WriteAttributeInteger($integerAttribute[$ident], $wert);
} elseif (isset($floatAttribute[$ident])) {
$wert = (float) $wert;
$this->WriteAttributeFloat($floatAttribute[$ident], $wert);
} elseif (isset($stringAttribute[$ident])) {
$wert = (string) $wert;
$this->WriteAttributeString($stringAttribute[$ident], $wert);
} elseif ($ident !== 'Leistungsquelle') {
throw new LogicException('Unbekannter interner Zustand: ' . $ident);
}
$variablenID = @IPS_GetObjectIDByIdent($ident, $this->InstanceID);
if (is_int($variablenID) && $variablenID > 0 && IPS_VariableExists($variablenID)) {
SetValue($variablenID, $wert);
}
}
/** @return bool|int|float|string */
private function leseZustand(string $ident)
{
$booleanAttribute = [
'SollwertGueltig' => 'ZustandSollwertGueltig',
'Verfuegbar' => 'ZustandVerfuegbar',
'AenderungMoeglich' => 'ZustandAenderungMoeglich',
'Stoerung' => 'ZustandStoerung',
'TemperaturGueltig' => 'ZustandTemperaturGueltig',
'NachladenAktiv' => 'ZustandNachladenAktiv',
'LegionellenbetriebAktiv' => 'ZustandLegionellenbetriebAktiv',
];
$integerAttribute = [
'Sollleistung' => 'ZustandSollleistung',
'AktiveStufe' => 'ZustandAktiveStufe',
'LegioCounter' => 'ZustandLegioCounter',
];
$floatAttribute = [
'Istleistung' => 'ZustandIstleistung',
'BezogeneEnergie' => 'ZustandBezogeneEnergie',
];
$stringAttribute = [
'Stoertext' => 'ZustandStoertext',
];
if (isset($booleanAttribute[$ident])) {
return $this->ReadAttributeBoolean($booleanAttribute[$ident]);
}
if (isset($integerAttribute[$ident])) {
return $this->ReadAttributeInteger($integerAttribute[$ident]);
}
if (isset($floatAttribute[$ident])) {
return $this->ReadAttributeFloat($floatAttribute[$ident]);
}
if (isset($stringAttribute[$ident])) {
return $this->ReadAttributeString($stringAttribute[$ident]);
}
throw new LogicException('Unbekannter interner Zustand: ' . $ident);
}
/** @return list<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));
}
/** @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
);
}
}