32 Commits
Author SHA1 Message Date
dh b404de4095 Modulstatus des einstufigen Verbrauchers testen
Tests / test (push) Failing after 38s
2026-09-17 09:43:43 +00:00
dh 9598a287f8 Modulstatus und Anleitung vervollstaendigen 2026-09-17 09:41:46 +00:00
dh 6c2d6ddcde Lastwechselsperre nur ueber Vertrag melden
Tests / test (push) Failing after 39s
2026-09-17 09:33:40 +00:00
dh d4828f1ee5 Verbraucher 1-Stufig eventbasiert implementieren
Tests / test (push) Failing after 39s
2026-09-17 09:32:17 +00:00
dh 29cfd8ceaa Manager-Diagnose und Verbrauchersuche verbessern
Tests / test (push) Failing after 40s
2026-09-17 09:30:26 +00:00
dh 92e8ec59df Leistungsangebot nach Lastwechsel sperren
Tests / test (push) Failing after 47s
2026-09-17 09:16:52 +00:00
dh 355e7163fb Lastwechsel ereignisbasiert sperren
Tests / test (push) Failing after 39s
2026-09-17 09:14:15 +00:00
dh 1b4a788f22 Manager-Konfiguration neu strukturieren
Tests / test (push) Failing after 46s
2026-09-17 08:55:43 +00:00
dh 9e6a675b25 Diagnosevariablen bedarfsgesteuert anlegen
Tests / test (push) Successful in 43s
2026-09-17 08:54:17 +00:00
dh 4514ccb557 Wassererwaermer-Konfiguration vereinfachen
Tests / test (push) Successful in 46s
2026-09-17 08:29:49 +00:00
dh 9869d99641 Modulalias für Wassererwärmer bereinigen
Tests / test (push) Successful in 40s
2026-09-17 08:06:08 +00:00
dh 98fb926fbd Modulhersteller und Anzeigenamen vereinheitlichen
Tests / test (push) Successful in 1m7s
2026-09-17 07:38:09 +00:00
dh 7a3c436039 Verbraucher Warmwassererwaermer implementieren
Tests / test (push) Canceled after 0s
2026-09-16 05:18:01 +00:00
dh 0a3ece3a81 Enelix-2-Manager implementieren
Tests / test (push) Canceled after 0s
Installierbares IP-Symcon-Manager-Modul mit testbarer Verteilungslogik, Verbraucher-Timeouts, Sollwertversand und aktualisierter Dokumentation.
2026-09-15 16:23:45 +00:00
dh 21a6466ca5 Dokumentation aktualisieren: libs/VerbraucherSchnittstelle.php
Tests / test (push) Canceled after 0s
2026-09-10 08:03:55 +00:00
dh 37304691eb Dokumentation aktualisieren: libs/ManagerSchnittstelle.php
Tests / test (push) Canceled after 0s
2026-09-10 08:03:49 +00:00
dh 8af7776f33 Dokumentation aktualisieren: docs/Schnittstelle.md
Tests / test (push) Canceled after 0s
2026-09-10 08:03:44 +00:00
dh 0c9d4d920a Dokumentation aktualisieren: README.md
Tests / test (push) Canceled after 0s
2026-09-10 08:03:38 +00:00
dh dcd310498d Entwurf: tests/VerbraucherBasisTraitTest.php
Tests / test (push) Canceled after 0s
2026-09-10 08:03:05 +00:00
dh c1fdd37b2b Entwurf: tests/DokumentationsstrukturTest.php
Tests / test (push) Canceled after 0s
2026-09-10 08:02:59 +00:00
dh 8439213234 Entwurf: libs/VerbraucherBasisTrait.php
Tests / test (push) Canceled after 0s
2026-09-10 08:02:53 +00:00
dh efda77ccad Entwurf: docs/module/Easee-Gateway/README.md
Tests / test (push) Canceled after 0s
2026-09-10 08:02:48 +00:00
dh 69da93270e Entwurf: docs/module/Ladestation-Gateway/README.md
Tests / test (push) Canceled after 0s
2026-09-10 08:02:42 +00:00
dh b2779148ee Entwurf: docs/module/Ladestation-Stand-Alone/README.md
Tests / test (push) Canceled after 0s
2026-09-10 08:02:37 +00:00
dh f10d454eb7 Entwurf: docs/module/Waermepumpe/README.md
Tests / test (push) Canceled after 0s
2026-09-10 08:02:31 +00:00
dh 9426b0f041 Entwurf: docs/module/Verbraucher-1-Stufig/README.md
Tests / test (push) Canceled after 0s
2026-09-10 08:02:26 +00:00
dh 1541482bf6 Entwurf: docs/module/Pufferspeicher/README.md
Tests / test (push) Canceled after 0s
2026-09-10 08:02:20 +00:00
dh 9acbff610d Entwurf: docs/module/Wassererwaermer/README.md
Tests / test (push) Canceled after 0s
2026-09-10 08:02:15 +00:00
dh 9aab4abdb3 Entwurf: Batterie
Tests / test (push) Canceled after 0s
2026-09-10 08:01:36 +00:00
dh 0f9860356d Entwurf: docs/module/Manager/README.md
Tests / test (push) Canceled after 0s
2026-09-10 08:00:35 +00:00
dh bd47faa593 Entwurf: docs/module/README.md
Tests / test (push) Canceled after 0s
2026-09-10 07:59:49 +00:00
dh 578285b0d6 Obere Manager-Anschlüsse dokumentieren
Tests / test (push) Canceled after 0s
2026-09-10 07:58:55 +00:00
42 changed files with 6012 additions and 9 deletions
+282
View File
@@ -0,0 +1,282 @@
{
"elements": [
{
"type": "ExpansionPanel",
"caption": "Lizenzierung",
"items": [
{
"type": "ValidationTextBox",
"name": "LizenzAnschluss",
"caption": "Konfiguration als JSON"
}
]
},
{
"type": "ExpansionPanel",
"caption": "Grundeinstellungen",
"expanded": true,
"items": [
{
"type": "Select",
"name": "Rolle",
"caption": "Rolle",
"options": [
{"caption": "Alleine", "value": 0},
{"caption": "Hauptmanager", "value": 1},
{"caption": "Untermanager", "value": 2}
]
},
{
"type": "SelectVariable",
"name": "NetzleistungVariableID",
"caption": "Netzleistung"
},
{
"type": "NumberSpinner",
"name": "SollwertSolarladen",
"caption": "Sollwert Solarladen",
"suffix": " W",
"digits": 1
},
{
"type": "Select",
"name": "Lastspitzenmodus",
"caption": "Lastspitzenmodus",
"onChange": "IPS_RequestAction($id, 'FormLastspitzenmodus', $Lastspitzenmodus);",
"options": [
{"caption": "Aus", "value": 0},
{"caption": "Konstant", "value": 1},
{"caption": "Monatlich", "value": 2}
]
},
{
"type": "NumberSpinner",
"name": "Lastspitzengrenze",
"caption": "Lastspitzengrenze",
"suffix": " W",
"digits": 1,
"visible": false
},
{
"type": "Button",
"name": "MonatsgrenzenUmschalten",
"caption": "Monatsgrenzen anzeigen",
"visible": false,
"onClick": "IPS_RequestAction($id, 'FormMonatsgrenzenUmschalten', true);"
},
{
"type": "List",
"name": "Monatsgrenzen",
"caption": "Monatliche Lastspitzengrenzen",
"rowCount": 12,
"add": false,
"delete": false,
"sortable": false,
"loadValuesFromConfiguration": false,
"visible": false,
"columns": [
{
"caption": "Monat",
"name": "Monat",
"width": "auto",
"save": true
},
{
"caption": "Grenze",
"name": "Grenze_W",
"width": "180px",
"save": true,
"edit": {
"type": "NumberSpinner",
"suffix": " W",
"digits": 1
}
},
{
"caption": "Monatsnummer",
"name": "MonatIndex",
"width": "0px",
"visible": false,
"save": true
}
],
"values": []
},
{
"type": "CheckBox",
"name": "AutomatischeSuche",
"caption": "Verbraucher automatisch suchen",
"onChange": "IPS_RequestAction($id, 'FormAutomatischeSuche', $AutomatischeSuche);"
},
{
"type": "SelectObject",
"name": "SuchbereichID",
"caption": "Suchbereich",
"visible": true
},
{
"type": "Button",
"name": "VerbraucherAktualisieren",
"caption": "Verbraucher aktualisieren",
"visible": true,
"onClick": "IPS_RequestAction($id, 'FormVerbraucherAktualisieren', json_encode(['SuchbereichID' => $SuchbereichID, 'Zuordnung' => $AutomatischeVerbraucherZuordnung]));"
},
{
"type": "List",
"name": "VerbraucherZuordnung",
"caption": "Verbraucher manuell auswaehlen",
"rowCount": 6,
"add": true,
"delete": true,
"sortable": true,
"visible": false,
"columns": [
{
"caption": "Instanz",
"name": "InstanzID",
"width": "auto",
"add": 0,
"edit": {"type": "SelectInstance"}
},
{
"caption": "Aktiv",
"name": "Aktiv",
"width": "100px",
"add": false,
"edit": {"type": "CheckBox"}
}
]
},
{
"type": "List",
"name": "AutomatischeVerbraucherZuordnung",
"caption": "Gefundene Verbraucher",
"rowCount": 8,
"add": false,
"delete": false,
"loadValuesFromConfiguration": false,
"visible": true,
"columns": [
{
"caption": "Verbraucher",
"name": "Name",
"width": "auto",
"save": true
},
{
"caption": "Instanz-ID",
"name": "InstanzID",
"width": "100px",
"save": true
},
{
"caption": "Verwenden",
"name": "Aktiv",
"width": "100px",
"save": true,
"edit": {"type": "CheckBox"}
}
],
"values": []
}
]
},
{
"type": "ExpansionPanel",
"caption": "Erweiterte Einstellungen",
"items": [
{
"type": "NumberSpinner",
"name": "KeepAlive",
"caption": "Keep alive",
"suffix": " s",
"minimum": 1
},
{
"type": "NumberSpinner",
"name": "VerbraucherTimeout",
"caption": "Verbraucher-Timeout",
"suffix": " s",
"minimum": 1
},
{
"type": "NumberSpinner",
"name": "MesswertMaxAlter",
"caption": "Maximales Messwertalter",
"suffix": " s",
"minimum": 1
},
{
"type": "NumberSpinner",
"name": "Umschaltdifferenz",
"caption": "Mindestdifferenz vor Umschaltung",
"suffix": " %",
"minimum": 0,
"maximum": 100,
"digits": 1
},
{
"type": "NumberSpinner",
"name": "Netzleistungsfaktor",
"caption": "Netzleistungsfaktor",
"digits": 4
},
{
"type": "CheckBox",
"name": "DiagnosevariablenAnzeigen",
"caption": "Diagnosevariablen anlegen"
},
{
"type": "CheckBox",
"name": "LoggingEin",
"caption": "Laufendes Debug-Logging aktivieren"
}
]
},
{
"type": "ExpansionPanel",
"caption": "Prognose / Forecast",
"items": [
{
"type": "ValidationTextBox",
"name": "PrognoseAnschluss",
"caption": "Konfiguration als JSON"
}
]
},
{
"type": "ExpansionPanel",
"caption": "SDL / VGT",
"items": [
{
"type": "ValidationTextBox",
"name": "SDLAnschluss",
"caption": "Konfiguration als JSON"
}
]
},
{
"type": "ExpansionPanel",
"caption": "Stoerueberwachung",
"items": [
{
"type": "ValidationTextBox",
"name": "StoermeldeAnschluss",
"caption": "Konfiguration als JSON"
}
]
}
],
"actions": [
{
"type": "Button",
"caption": "Regelung jetzt ausfuehren",
"onClick": "IPS_RequestAction($id, 'Regeln', true);"
}
],
"status": [
{"code": 102, "icon": "active", "caption": "Aktiv"},
{"code": 201, "icon": "inactive", "caption": "Netzleistungsmessung fehlt oder ist veraltet"},
{"code": 202, "icon": "error", "caption": "Konfiguration ungueltig"}
]
}
+14
View File
@@ -0,0 +1,14 @@
{
"id": "{6F771B18-59D4-4C8A-B951-3B2FE9F6A2C4}",
"name": "Manager",
"type": 3,
"vendor": "Enelix",
"aliases": [
"Manager"
],
"parentRequirements": [],
"childRequirements": [],
"implemented": [],
"prefix": "ENELIX",
"url": "https://git.belevo.ch/ENELIX/Enelix-EMS/src/branch/develop/Manager"
}
+796
View File
@@ -0,0 +1,796 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../libs/ManagerSchnittstelle.php';
require_once __DIR__ . '/../libs/Nachrichtenvertrag.php';
require_once __DIR__ . '/../libs/ManagerRegler.php';
use Belevo\EnelixEMS\ManagerRegler;
use Belevo\EnelixEMS\ManagerSchnittstelle;
use Belevo\EnelixEMS\Nachrichtenvertrag;
class Manager extends IPSModule implements ManagerSchnittstelle
{
private const STATUS_AKTIV = 102;
private const STATUS_MESSWERT_UNGUELTIG = 201;
private const STATUS_KONFIGURATION_UNGUELTIG = 202;
private const VM_UPDATE = 10603;
private const VERBRAUCHER_MODUL_IDS = [
'{B7C54AF4-AD7D-4FE4-B75D-203693906251}',
'{15879A4E-D0C2-4495-83DE-46E1E462591E}',
'{15879A4E-D0C2-4495-83DE-46E1E462591E}',
];
private const MONATSNAMEN = [
1 => 'Januar', 'Februar', 'Maerz', 'April', 'Mai', 'Juni',
'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember',
];
private const DIAGNOSE_VARIABLEN = [
'NetzleistungGueltig',
'WirksameLastspitzengrenze',
'Verteilbudget',
'VerbraucherAnzahl',
'VerbraucherVerfuegbar',
'Verbraucherstatus',
'Monatsgrenzen',
'Prognosestatus',
'SDLStatus',
'Lizenzstatus',
'Stoerueberwachungsstatus',
'Sammelstoerung',
'Stoertext',
];
public function Create(): void
{
parent::Create();
$this->RegisterPropertyInteger('Rolle', 0);
$this->RegisterPropertyInteger('NetzleistungVariableID', 0);
$this->RegisterPropertyFloat('Netzleistungsfaktor', 1.0);
$this->RegisterPropertyInteger('MesswertMaxAlter', 60);
$this->RegisterPropertyString('VerbraucherZuordnung', '[]');
$this->RegisterPropertyString('AutomatischeVerbraucherZuordnung', '[]');
$this->RegisterPropertyBoolean('AutomatischeSuche', true);
$this->RegisterPropertyInteger('SuchbereichID', 0);
$this->RegisterPropertyInteger('KeepAlive', 60);
$this->RegisterPropertyInteger('VerbraucherTimeout', 60);
$this->RegisterPropertyInteger('Lastspitzenmodus', 0);
$this->RegisterPropertyFloat('Lastspitzengrenze', 0.0);
$this->RegisterPropertyString('Monatsgrenzen', $this->standardMonatsgrenzen());
$this->RegisterPropertyFloat('SollwertSolarladen', 0.0);
$this->RegisterPropertyFloat('Umschaltdifferenz', 5.0);
$this->RegisterPropertyString('PrognoseAnschluss', '{}');
$this->RegisterPropertyString('SDLAnschluss', '{}');
$this->RegisterPropertyString('LizenzAnschluss', '{}');
$this->RegisterPropertyString('StoermeldeAnschluss', '{}');
$this->RegisterPropertyBoolean('DiagnosevariablenAnzeigen', false);
$this->RegisterPropertyBoolean('LoggingEin', false);
$this->RegisterVariableBoolean('Aktiv', 'Aktiv', '~Switch', 10);
$this->EnableAction('Aktiv');
$this->RegisterVariableString('Betriebsart', 'Betriebsart', '', 20);
$this->RegisterVariableFloat('Netzleistung', 'Netzleistung', '', 30);
$this->RegisterAttributeString('VerbraucherCache', '{}');
$this->RegisterAttributeString('LetzteSollwerte', '{}');
$this->RegisterAttributeInteger('RegistrierteNetzleistungVariableID', 0);
// Die alten Timer bleiben deaktiviert registriert, damit bestehende Instanzen sauber migrieren.
$this->RegisterTimer('Regelzyklus', 0, '');
$this->RegisterTimer('VorgabenErneuern', 0, '');
$this->RegisterTimer(
'KeepAlive',
0,
"IPS_RequestAction(\$_IPS['TARGET'], 'Regeln', true);"
);
}
public function ApplyChanges(): void
{
parent::ApplyChanges();
$this->aktualisiereVariablen();
try {
$this->pruefeKonfiguration();
} catch (Throwable $fehler) {
$this->SetStatus(self::STATUS_KONFIGURATION_UNGUELTIG);
$this->SetTimerInterval('Regelzyklus', 0);
$this->SetTimerInterval('VorgabenErneuern', 0);
$this->SetTimerInterval('KeepAlive', 0);
$this->protokolliere('Konfiguration', $fehler->getMessage());
return;
}
$this->registriereNetzleistungsmeldung();
$this->SetTimerInterval('Regelzyklus', 0);
$this->SetTimerInterval('VorgabenErneuern', 0);
$this->SetTimerInterval('KeepAlive', $this->ReadPropertyInteger('KeepAlive') * 1000);
$this->setzeDiagnosewert('Monatsgrenzen', $this->ReadPropertyString('Monatsgrenzen'));
$this->aktualisiereAnschlussstatus();
$this->SetStatus(self::STATUS_AKTIV);
}
public function GetConfigurationForm(): string
{
$formular = json_decode(
(string) file_get_contents(__DIR__ . '/form.json'),
true,
512,
JSON_THROW_ON_ERROR
);
$automatisch = $this->ReadPropertyBoolean('AutomatischeSuche');
$lastspitzenmodus = $this->ReadPropertyInteger('Lastspitzenmodus');
$monatsgrenzenSichtbar = $lastspitzenmodus === 2
&& $this->GetBuffer('MonatsgrenzenSichtbar') === '1';
$this->setzeFormularParameter($formular['elements'], 'VerbraucherZuordnung', 'visible', !$automatisch);
$this->setzeFormularParameter(
$formular['elements'],
'AutomatischeVerbraucherZuordnung',
'visible',
$automatisch
);
$this->setzeFormularParameter($formular['elements'], 'SuchbereichID', 'visible', $automatisch);
$this->setzeFormularParameter(
$formular['elements'],
'VerbraucherAktualisieren',
'visible',
$automatisch
);
$this->setzeFormularParameter(
$formular['elements'],
'AutomatischeVerbraucherZuordnung',
'values',
$this->automatischeVerbraucherFuerFormular()
);
$this->setzeFormularParameter(
$formular['elements'],
'Lastspitzengrenze',
'visible',
$lastspitzenmodus === 1
);
$this->setzeFormularParameter(
$formular['elements'],
'MonatsgrenzenUmschalten',
'visible',
$lastspitzenmodus === 2
);
$this->setzeFormularParameter(
$formular['elements'],
'MonatsgrenzenUmschalten',
'caption',
$monatsgrenzenSichtbar ? 'Monatsgrenzen ausblenden' : 'Monatsgrenzen anzeigen'
);
$this->setzeFormularParameter(
$formular['elements'],
'Monatsgrenzen',
'visible',
$monatsgrenzenSichtbar
);
$this->setzeFormularParameter(
$formular['elements'],
'Monatsgrenzen',
'values',
$this->monatsgrenzenFuerFormular()
);
return json_encode($formular, JSON_THROW_ON_ERROR);
}
public function MessageSink($zeitstempel, $senderID, $nachricht, $daten): void
{
if ((int) $senderID === $this->ReadPropertyInteger('NetzleistungVariableID')
&& (int) $nachricht === self::VM_UPDATE
) {
$this->regeln(false);
}
}
public function RequestAction($ident, $wert): void
{
switch ($ident) {
case 'Aktiv':
$this->SetValue('Aktiv', (bool) $wert);
$this->regeln(false);
return;
case 'Regeln':
$this->regeln((bool) $wert);
return;
case 'FormAutomatischeSuche':
$automatisch = (bool) $wert;
$this->UpdateFormField('VerbraucherZuordnung', 'visible', !$automatisch);
$this->UpdateFormField('AutomatischeVerbraucherZuordnung', 'visible', $automatisch);
$this->UpdateFormField('SuchbereichID', 'visible', $automatisch);
$this->UpdateFormField('VerbraucherAktualisieren', 'visible', $automatisch);
return;
case 'FormVerbraucherAktualisieren':
if (!is_string($wert)) {
throw new InvalidArgumentException('Suchparameter muessen als JSON uebergeben werden.');
}
$suchparameter = json_decode($wert, true, 512, JSON_THROW_ON_ERROR);
if (!is_array($suchparameter) || !is_array($suchparameter['Zuordnung'] ?? null)) {
throw new InvalidArgumentException('Suchparameter sind ungueltig.');
}
$suchbereichID = $suchparameter['SuchbereichID'] ?? 0;
if (!is_int($suchbereichID)) {
throw new InvalidArgumentException('SuchbereichID muss eine Ganzzahl sein.');
}
$werte = $this->automatischeVerbraucherFuerFormular(
$suchparameter['Zuordnung'],
$suchbereichID
);
$this->UpdateFormField(
'AutomatischeVerbraucherZuordnung',
'values',
json_encode($werte, JSON_THROW_ON_ERROR)
);
return;
case 'FormLastspitzenmodus':
$modus = (int) $wert;
$this->UpdateFormField('Lastspitzengrenze', 'visible', $modus === 1);
$this->UpdateFormField('MonatsgrenzenUmschalten', 'visible', $modus === 2);
if ($modus !== 2) {
$this->SetBuffer('MonatsgrenzenSichtbar', '0');
$this->UpdateFormField('Monatsgrenzen', 'visible', false);
$this->UpdateFormField(
'MonatsgrenzenUmschalten',
'caption',
'Monatsgrenzen anzeigen'
);
}
return;
case 'FormMonatsgrenzenUmschalten':
$sichtbar = $this->GetBuffer('MonatsgrenzenSichtbar') !== '1';
$this->SetBuffer('MonatsgrenzenSichtbar', $sichtbar ? '1' : '0');
$this->UpdateFormField('Monatsgrenzen', 'visible', $sichtbar);
$this->UpdateFormField(
'MonatsgrenzenUmschalten',
'caption',
$sichtbar ? 'Monatsgrenzen ausblenden' : 'Monatsgrenzen anzeigen'
);
return;
case 'VerbraucherdatenEmpfangen':
if (!is_string($wert)) {
throw new InvalidArgumentException('Verbraucherdaten muessen als JSON uebergeben werden.');
}
$daten = json_decode($wert, true, 512, JSON_THROW_ON_ERROR);
if (!is_array($daten)) {
throw new InvalidArgumentException('Verbraucherdaten muessen ein JSON-Objekt sein.');
}
$this->VerbraucherdatenEmpfangen($daten);
return;
}
throw new InvalidArgumentException('Unbekannte Aktion: ' . $ident);
}
/**
* @param array<string, mixed> $daten
*/
public function VerbraucherdatenEmpfangen(array $daten): void
{
Nachrichtenvertrag::pruefeVerbraucherdaten($daten);
if ($daten['Kopf']['EmpfaengerID'] !== $this->InstanceID) {
throw new InvalidArgumentException('Verbraucherdaten sind an eine andere Instanz adressiert.');
}
$absenderID = $daten['Kopf']['AbsenderID'];
if (!in_array($absenderID, $this->aktiveVerbraucherIDs(), true)) {
throw new InvalidArgumentException('Der Absender ist diesem Manager nicht aktiv zugeordnet.');
}
$cache = $this->leseJsonAttribut('VerbraucherCache');
$cache[(string) $absenderID] = [
'EmpfangenAm' => time(),
'Daten' => $daten,
];
$this->WriteAttributeString('VerbraucherCache', json_encode($cache, JSON_THROW_ON_ERROR));
$this->regeln(false);
}
private function regeln(bool $erneuern): void
{
[$netzleistung, $netzleistungGueltig] = $this->leseNetzleistung();
$this->SetValue('Netzleistung', $netzleistung);
$this->setzeDiagnosewert('NetzleistungGueltig', $netzleistungGueltig);
$aktiv = (bool) $this->GetValue('Aktiv');
if (!$aktiv || !$netzleistungGueltig) {
$this->SetValue('Betriebsart', ManagerRegler::BETRIEBSART_INAKTIV);
$this->setzeDiagnosewert('Verteilbudget', 0.0);
$this->SetStatus($aktiv ? self::STATUS_MESSWERT_UNGUELTIG : self::STATUS_AKTIV);
return;
}
$stoerungen = [];
$verbraucher = $this->aktuelleVerbraucher($stoerungen);
$lastspitzengrenze = ManagerRegler::wirksameLastspitzengrenze(
$this->ReadPropertyInteger('Lastspitzenmodus'),
$this->ReadPropertyFloat('Lastspitzengrenze'),
$this->dekodiereMonatsgrenzen($this->ReadPropertyString('Monatsgrenzen')),
(int) date('n')
);
$betriebsart = ManagerRegler::bestimmeBetriebsart(
true,
true,
$netzleistung,
(string) $this->GetValue('Betriebsart'),
$lastspitzengrenze !== null,
$this->ReadPropertyFloat('SollwertSolarladen'),
$lastspitzengrenze ?? 0.0,
$this->ReadPropertyFloat('Umschaltdifferenz')
);
$zielnetzleistung = $betriebsart === ManagerRegler::BETRIEBSART_PEAK
? (float) $lastspitzengrenze
: $this->ReadPropertyFloat('SollwertSolarladen');
$ergebnis = ManagerRegler::verteile(
$netzleistung,
$zielnetzleistung,
$verbraucher,
$betriebsart === ManagerRegler::BETRIEBSART_PEAK ? 'PrioritaetPeak' : 'PrioritaetPV'
);
$this->SetValue('Betriebsart', $betriebsart);
$this->setzeDiagnosewert('WirksameLastspitzengrenze', $lastspitzengrenze ?? 0.0);
$this->setzeDiagnosewert('Verteilbudget', (float) $ergebnis['Verteilbudget_W']);
$this->setzeDiagnosewert('Verbraucherstatus', json_encode([
'Betriebsart' => $betriebsart,
'Restabweichung_W' => $ergebnis['Restabweichung_W'],
'Verbraucher' => $verbraucher,
], JSON_THROW_ON_ERROR));
$letzteSollwerte = $this->leseJsonAttribut('LetzteSollwerte');
foreach ($ergebnis['Sollwerte'] as $instanzID => $sollleistung) {
if (!$erneuern && ($letzteSollwerte[(string) $instanzID] ?? null) === $sollleistung) {
continue;
}
try {
$this->sendeSollwert((int) $instanzID, $sollleistung);
$letzteSollwerte[(string) $instanzID] = $sollleistung;
} catch (Throwable $fehler) {
$stoerungen[] = 'Sollwert an Instanz ' . $instanzID . ': ' . $fehler->getMessage();
}
}
$this->WriteAttributeString('LetzteSollwerte', json_encode($letzteSollwerte, JSON_THROW_ON_ERROR));
$this->aktualisiereStoerungen($verbraucher, $stoerungen);
$this->SetStatus(self::STATUS_AKTIV);
}
/** @return array{0: float, 1: bool} */
private function leseNetzleistung(): array
{
$variablenID = $this->ReadPropertyInteger('NetzleistungVariableID');
if ($variablenID <= 0 || !IPS_VariableExists($variablenID)) {
return [0.0, false];
}
$variable = IPS_GetVariable($variablenID);
if (time() - (int) $variable['VariableUpdated'] > $this->ReadPropertyInteger('MesswertMaxAlter')) {
return [0.0, false];
}
$wert = GetValue($variablenID);
if (!is_int($wert) && !is_float($wert)) {
return [0.0, false];
}
return [(float) $wert * $this->ReadPropertyFloat('Netzleistungsfaktor'), true];
}
/**
* @param list<string> $stoerungen
* @return list<array{InstanzID: int, Daten: array<string, mixed>}>
*/
private function aktuelleVerbraucher(array &$stoerungen): array
{
$ids = $this->aktiveVerbraucherIDs();
$this->setzeDiagnosewert('VerbraucherAnzahl', count($ids));
$cache = $this->leseJsonAttribut('VerbraucherCache');
$timeout = $this->ReadPropertyInteger('VerbraucherTimeout');
$aktuell = [];
foreach ($ids as $instanzID) {
$eintrag = $cache[(string) $instanzID] ?? null;
if (!is_array($eintrag) || !isset($eintrag['EmpfangenAm'], $eintrag['Daten'])) {
$stoerungen[] = 'Noch keine Meldung von Instanz ' . $instanzID . '.';
continue;
}
if (time() - (int) $eintrag['EmpfangenAm'] > $timeout) {
$stoerungen[] = 'Meldung von Instanz ' . $instanzID . ' ist veraltet.';
continue;
}
$aktuell[] = ['InstanzID' => $instanzID, 'Daten' => $eintrag['Daten']];
}
$verfuegbar = array_filter($aktuell, static fn (array $eintrag): bool => $eintrag['Daten']['Verfuegbar']);
$this->setzeDiagnosewert('VerbraucherVerfuegbar', count($verfuegbar));
return $aktuell;
}
/** @return list<int> */
private function aktiveVerbraucherIDs(): array
{
if (!$this->ReadPropertyBoolean('AutomatischeSuche')) {
return $this->leseAktiveVerbraucherIDs('VerbraucherZuordnung');
}
$gespeichert = json_decode(
$this->ReadPropertyString('AutomatischeVerbraucherZuordnung'),
true,
512,
JSON_THROW_ON_ERROR
);
if ($gespeichert === []) {
// Bestehende Installationen behalten ihre Auswahl, bis die neue Liste gespeichert wurde.
return $this->leseAktiveVerbraucherIDs('VerbraucherZuordnung');
}
return $this->leseAktiveVerbraucherIDs('AutomatischeVerbraucherZuordnung');
}
/** @return list<int> */
private function leseAktiveVerbraucherIDs(string $property): array
{
$zuordnung = json_decode($this->ReadPropertyString($property), true, 512, JSON_THROW_ON_ERROR);
if (!is_array($zuordnung)) {
throw new InvalidArgumentException($property . ' muss eine JSON-Liste sein.');
}
return $this->aktiveVerbraucherIDsAusZuordnung($zuordnung);
}
/**
* @param list<array<string, mixed>> $zuordnung
* @return list<int>
*/
private function aktiveVerbraucherIDsAusZuordnung(array $zuordnung): array
{
$ids = [];
foreach ($zuordnung as $eintrag) {
if (!is_array($eintrag)) {
throw new InvalidArgumentException('Jede Verbraucherzuordnung muss ein Objekt sein.');
}
$instanzID = $eintrag['InstanzID'] ?? $eintrag['Verbraucher'] ?? null;
if (!is_int($instanzID) || $instanzID <= 0 || !is_bool($eintrag['Aktiv'] ?? null)) {
throw new InvalidArgumentException('Verbraucherzuordnung benoetigt InstanzID und Aktiv.');
}
if ($eintrag['Aktiv']) {
$ids[] = $instanzID;
}
}
return array_values(array_unique($ids));
}
/**
* @param list<array<string, mixed>>|null $aktuelleZuordnung
* @return list<array{Name: string, InstanzID: int, Aktiv: bool}>
*/
private function automatischeVerbraucherFuerFormular(
?array $aktuelleZuordnung = null,
?int $suchbereichID = null
): array {
if ($aktuelleZuordnung === null) {
$gespeichert = json_decode(
$this->ReadPropertyString('AutomatischeVerbraucherZuordnung'),
true,
512,
JSON_THROW_ON_ERROR
);
$ausgewaehlt = $gespeichert === []
? $this->leseAktiveVerbraucherIDs('VerbraucherZuordnung')
: $this->aktiveVerbraucherIDsAusZuordnung($gespeichert);
} else {
$ausgewaehlt = $this->aktiveVerbraucherIDsAusZuordnung($aktuelleZuordnung);
}
$suchbereichID ??= $this->ReadPropertyInteger('SuchbereichID');
$werte = [];
foreach (self::VERBRAUCHER_MODUL_IDS as $modulID) {
foreach (IPS_GetInstanceListByModuleID($modulID) as $instanzID) {
if (!$this->istImSuchbereich($instanzID, $suchbereichID)) {
continue;
}
$werte[] = [
'Name' => IPS_GetName($instanzID),
'InstanzID' => $instanzID,
'Aktiv' => in_array($instanzID, $ausgewaehlt, true),
];
}
}
usort($werte, static function (array $links, array $rechts): int {
$nameVergleich = strcasecmp($links['Name'], $rechts['Name']);
return $nameVergleich !== 0
? $nameVergleich
: $links['InstanzID'] <=> $rechts['InstanzID'];
});
return $werte;
}
private function istImSuchbereich(int $objektID, int $suchbereichID): bool
{
if ($suchbereichID <= 0) {
return true;
}
while ($objektID > 0) {
if ($objektID === $suchbereichID) {
return true;
}
$objektID = IPS_GetParent($objektID);
}
return false;
}
/** @return list<array{Monat: string, Grenze_W: float, MonatIndex: int}> */
private function monatsgrenzenFuerFormular(): array
{
$grenzen = $this->dekodiereMonatsgrenzen($this->ReadPropertyString('Monatsgrenzen'));
$werte = [];
foreach (self::MONATSNAMEN as $monat => $name) {
$werte[] = [
'Monat' => $name,
'Grenze_W' => $grenzen[$monat],
'MonatIndex' => $monat,
];
}
return $werte;
}
/** @param list<array<string, mixed>> $elemente */
private function setzeFormularParameter(
array &$elemente,
string $name,
string $parameter,
$wert
): bool {
foreach ($elemente as &$element) {
if (($element['name'] ?? null) === $name) {
$element[$parameter] = $wert;
return true;
}
if (isset($element['items'])
&& is_array($element['items'])
&& $this->setzeFormularParameter($element['items'], $name, $parameter, $wert)
) {
return true;
}
}
unset($element);
return false;
}
private function sendeSollwert(int $instanzID, int $sollleistung): void
{
if (!IPS_InstanceExists($instanzID)) {
throw new RuntimeException('Instanz existiert nicht.');
}
$paket = [
'Kopf' => [
'Version' => Nachrichtenvertrag::VERSION,
'AbsenderID' => $this->InstanceID,
'EmpfaengerID' => $instanzID,
'Zeitpunkt' => time(),
],
'Sollleistung_W' => $sollleistung,
];
Nachrichtenvertrag::pruefeManagerdaten($paket);
IPS_RequestAction($instanzID, 'ManagerdatenEmpfangen', json_encode($paket, JSON_THROW_ON_ERROR));
$this->protokolliere('Sollwert', ['InstanzID' => $instanzID, 'Sollleistung_W' => $sollleistung]);
}
/**
* @param list<array{InstanzID: int, Daten: array<string, mixed>}> $verbraucher
* @param list<string> $stoerungen
*/
private function aktualisiereStoerungen(array $verbraucher, array $stoerungen): void
{
foreach ($verbraucher as $eintrag) {
foreach ($eintrag['Daten']['Zustand'] as $zustand) {
if ($zustand['Art'] === 'Stoerung' && $zustand['Wert'] === true) {
$stoerungen[] = 'Instanz ' . $eintrag['InstanzID'] . ': '
. ($zustand['Text'] ?? $zustand['Kennung']);
}
}
}
$stoerungen = array_values(array_unique($stoerungen));
$this->setzeDiagnosewert('Sammelstoerung', $stoerungen !== []);
$this->setzeDiagnosewert('Stoertext', implode("\n", $stoerungen));
}
private function registriereNetzleistungsmeldung(): void
{
$alt = $this->ReadAttributeInteger('RegistrierteNetzleistungVariableID');
$neu = $this->ReadPropertyInteger('NetzleistungVariableID');
if ($alt > 0 && $alt !== $neu) {
$this->UnregisterMessage($alt, self::VM_UPDATE);
}
if ($neu > 0 && $neu !== $alt) {
$this->RegisterMessage($neu, self::VM_UPDATE);
}
$this->WriteAttributeInteger('RegistrierteNetzleistungVariableID', $neu);
}
private function aktualisiereVariablen(): void
{
if ($this->ReadPropertyBoolean('DiagnosevariablenAnzeigen')) {
$this->RegisterVariableBoolean(
'NetzleistungGueltig',
'Netzleistung gueltig',
'~Switch',
40
);
$this->RegisterVariableFloat(
'WirksameLastspitzengrenze',
'Wirksame Lastspitzengrenze',
'',
50
);
$this->RegisterVariableFloat('Verteilbudget', 'Verteilbudget', '', 60);
$this->RegisterVariableInteger('VerbraucherAnzahl', 'Verbraucher Anzahl', '', 70);
$this->RegisterVariableInteger(
'VerbraucherVerfuegbar',
'Verbraucher verfuegbar',
'',
80
);
$this->RegisterVariableString('Verbraucherstatus', 'Verbraucherstatus', '', 90);
$this->RegisterVariableString('Monatsgrenzen', 'Monatsgrenzen', '', 100);
$this->RegisterVariableString('Prognosestatus', 'Prognosestatus', '', 110);
$this->RegisterVariableString('SDLStatus', 'SDL-Status', '', 120);
$this->RegisterVariableString('Lizenzstatus', 'Lizenzstatus', '', 130);
$this->RegisterVariableString(
'Stoerueberwachungsstatus',
'Stoerueberwachungsstatus',
'',
140
);
$this->RegisterVariableBoolean('Sammelstoerung', 'Sammelstoerung', '~Alert', 150);
$this->RegisterVariableString('Stoertext', 'Stoertext', '', 160);
return;
}
foreach (self::DIAGNOSE_VARIABLEN as $ident) {
$variablenID = @IPS_GetObjectIDByIdent($ident, $this->InstanceID);
if (is_int($variablenID) && $variablenID > 0 && IPS_VariableExists($variablenID)) {
$this->UnregisterVariable($ident);
}
}
}
/** @param bool|float|int|string $wert */
private function setzeDiagnosewert(string $ident, $wert): void
{
$variablenID = @IPS_GetObjectIDByIdent($ident, $this->InstanceID);
if (is_int($variablenID) && $variablenID > 0 && IPS_VariableExists($variablenID)) {
$this->SetValue($ident, $wert);
}
}
private function aktualisiereAnschlussstatus(): void
{
foreach ([
'PrognoseAnschluss' => 'Prognosestatus',
'SDLAnschluss' => 'SDLStatus',
'LizenzAnschluss' => 'Lizenzstatus',
'StoermeldeAnschluss' => 'Stoerueberwachungsstatus',
] as $property => $variable) {
$konfiguration = json_decode($this->ReadPropertyString($property), true, 512, JSON_THROW_ON_ERROR);
$aktiv = is_array($konfiguration) && ($konfiguration['Aktiv'] ?? false) === true;
$this->setzeDiagnosewert($variable, $aktiv ? 'Fehler' : 'NichtVerwendet');
}
}
private function pruefeKonfiguration(): void
{
foreach (['MesswertMaxAlter', 'KeepAlive', 'VerbraucherTimeout'] as $property) {
if ($this->ReadPropertyInteger($property) <= 0) {
throw new InvalidArgumentException($property . ' muss groesser als 0 sein.');
}
}
if ($this->ReadPropertyFloat('Umschaltdifferenz') < 0
|| $this->ReadPropertyFloat('Umschaltdifferenz') > 100
) {
throw new InvalidArgumentException('Umschaltdifferenz muss zwischen 0 und 100 liegen.');
}
$this->leseAktiveVerbraucherIDs('VerbraucherZuordnung');
$this->leseAktiveVerbraucherIDs('AutomatischeVerbraucherZuordnung');
$this->dekodiereMonatsgrenzen($this->ReadPropertyString('Monatsgrenzen'));
foreach (['PrognoseAnschluss', 'SDLAnschluss', 'LizenzAnschluss', 'StoermeldeAnschluss'] as $property) {
$wert = json_decode($this->ReadPropertyString($property), true, 512, JSON_THROW_ON_ERROR);
if (!is_array($wert)) {
throw new InvalidArgumentException($property . ' muss ein JSON-Objekt sein.');
}
}
}
/** @return array<int, float> */
private function dekodiereMonatsgrenzen(string $json): array
{
$werte = json_decode($json, true, 512, JSON_THROW_ON_ERROR);
if (!is_array($werte)) {
throw new InvalidArgumentException('Monatsgrenzen muss ein JSON-Objekt sein.');
}
$ergebnis = [];
if (isset($werte[0]) && is_array($werte[0])) {
foreach ($werte as $eintrag) {
if (!is_array($eintrag)) {
throw new InvalidArgumentException('Monatsgrenzen muss eine Liste von Monatswerten sein.');
}
$monat = $eintrag['MonatIndex'] ?? null;
$wert = $eintrag['Grenze_W'] ?? null;
if (!is_int($monat) || $monat < 1 || $monat > 12
|| (!is_int($wert) && !is_float($wert))
) {
throw new InvalidArgumentException('Jeder Monatswert benoetigt Monatsnummer und Grenze.');
}
$ergebnis[$monat] = (float) $wert;
}
} else {
for ($monat = 1; $monat <= 12; ++$monat) {
$wert = $werte[(string) $monat] ?? $werte[$monat] ?? null;
if (!is_int($wert) && !is_float($wert)) {
throw new InvalidArgumentException(
'Monatsgrenzen muss fuer jeden Monat einen Zahlenwert enthalten.'
);
}
$ergebnis[$monat] = (float) $wert;
}
}
if (count($ergebnis) !== 12) {
throw new InvalidArgumentException('Monatsgrenzen muss genau zwoelf Monatswerte enthalten.');
}
ksort($ergebnis);
return $ergebnis;
}
/** @return array<string, mixed> */
private function leseJsonAttribut(string $name): array
{
$wert = json_decode($this->ReadAttributeString($name), true, 512, JSON_THROW_ON_ERROR);
return is_array($wert) ? $wert : [];
}
private function standardMonatsgrenzen(): string
{
$werte = [];
foreach (self::MONATSNAMEN as $monat => $name) {
$werte[] = [
'Monat' => $name,
'Grenze_W' => 0.0,
'MonatIndex' => $monat,
];
}
return json_encode($werte, JSON_THROW_ON_ERROR);
}
/** @param mixed $daten */
private function protokolliere(string $bezeichnung, $daten): void
{
if ($this->ReadPropertyBoolean('LoggingEin')) {
$this->SendDebug($bezeichnung, is_string($daten) ? $daten : json_encode($daten), 0);
}
}
}
+20 -5
View File
@@ -4,20 +4,27 @@ Energiemanagement, Manager und steuerbare Verbraucher fuer IP-Symcon.
## Status
Das Repository befindet sich im Aufbau. Aktuell ist ausschliesslich der gemeinsame Nachrichtenvertrag implementiert. Noch nicht implementierte Module werden nicht als leere Platzhalter angelegt.
Das Repository befindet sich im Aufbau. Der gemeinsame Nachrichtenvertrag, die
Verbraucherbasis, der Manager, der mehrstufige Warmwassererwaermer und der
1-stufige Verbraucher sind implementiert. Die weiteren Module sind als
Diskussionsentwürfe dokumentiert.
## Geplante Module
- Manager
- Manager (implementiert)
- Batterie
- Wassererwaermer
- Wassererwaermer (implementiert)
- Pufferspeicher
- Verbraucher 1-Stufig
- Verbraucher 1-Stufig (implementiert)
- Waermepumpe
- Ladestation Stand-Alone
- Ladestation Gateway
- Easee Gateway
Die Properties, Variablen, Zustandsdaten und offenen Punkte stehen in der
[Modulübersicht](docs/module/README.md). Gemeinsame Felder werden dort nicht
abweichend neu definiert, sondern verweisen auf den zentralen Vertrag.
Die Verbraucher kommunizieren mit dem Manager ueber den gemeinsamen Vertrag in `libs/`. Der Manager waehlt die Verbraucher aus; in einem Verbraucher gibt es keine Manager-ID-Property.
## Voraussetzungen
@@ -40,5 +47,13 @@ Neue oder geaenderte Funktionen werden zusammen mit ihren PHPUnit-Tests eingeche
| `beta` | Beta |
| `develop` | Testing |
Die ausfuehrliche Schnittstellenbeschreibung steht unter [docs/Schnittstelle.md](docs/Schnittstelle.md).
## Dokumentation
- [Manager-Verbraucher-Schnittstelle](docs/Schnittstelle.md)
- [Manager-Modul](docs/module/Manager/README.md)
- [Warmwassererwaermer-Modul](docs/module/Wassererwaermer/README.md)
- [Verbraucher-1-Stufig-Modul](docs/module/Verbraucher-1-Stufig/README.md)
- [Migration Boiler x-Stufig](docs/migration/Boiler-x-Stufig.md)
- [Migration Verbraucher 1-Stufig](docs/migration/Verbraucher-1-Stufig.md)
- [Obere Anschlüsse des Managers](docs/Obere-Anschluesse.md)
- [Modulübersicht](docs/module/README.md)
+125
View File
@@ -0,0 +1,125 @@
{
"elements": [
{
"type": "ExpansionPanel",
"caption": "Manager und Zeitverhalten",
"expanded": true,
"items": [
{
"type": "NumberSpinner",
"name": "PrioritaetPV",
"caption": "Prioritaet PV",
"minimum": 0
},
{
"type": "NumberSpinner",
"name": "PrioritaetPeak",
"caption": "Prioritaet Peak",
"minimum": 0
},
{
"type": "NumberSpinner",
"name": "Meldeintervall",
"caption": "Meldeintervall",
"suffix": " s",
"minimum": 1
},
{
"type": "NumberSpinner",
"name": "VorgabeTimeout",
"caption": "Vorgabe-Timeout",
"suffix": " s",
"minimum": 1
},
{
"type": "NumberSpinner",
"name": "Umschaltabstand",
"caption": "Zeit bis zum naechsten Lastwechsel",
"suffix": " s",
"minimum": 0
}
]
},
{
"type": "ExpansionPanel",
"caption": "Verbraucher",
"expanded": true,
"items": [
{
"type": "NumberSpinner",
"name": "Nennleistung",
"caption": "Nennleistung",
"suffix": " W",
"minimum": 1
},
{
"type": "SelectVariable",
"name": "SchaltkontaktVariableID",
"caption": "Schaltkontakt",
"validVariableTypes": [
0
]
},
{
"type": "CheckBox",
"name": "SchaltkontaktInvertiert",
"caption": "Schaltkontakt invertieren"
},
{
"type": "SelectVariable",
"name": "RueckmeldungVariableID",
"caption": "Optionale Schaltzustands-Rueckmeldung",
"validVariableTypes": [
0
]
},
{
"type": "NumberSpinner",
"name": "Mindestlaufzeit",
"caption": "Taegliche Mindestlaufzeit",
"suffix": " s",
"minimum": 0,
"maximum": 86400
}
]
},
{
"type": "ExpansionPanel",
"caption": "Darstellung und Diagnose",
"items": [
{
"type": "CheckBox",
"name": "EinstellungenInVisu",
"caption": "Lokale Einstellungen in der Visualisierung anzeigen"
},
{
"type": "CheckBox",
"name": "DiagnosevariablenAnzeigen",
"caption": "Diagnosevariablen anzeigen"
},
{
"type": "CheckBox",
"name": "LoggingEin",
"caption": "Diagnoseprotokoll aktivieren"
}
]
}
],
"status": [
{
"code": 102,
"icon": "active",
"caption": "Aktiv"
},
{
"code": 201,
"icon": "error",
"caption": "Konfiguration ungueltig"
},
{
"code": 202,
"icon": "error",
"caption": "Schalt- oder Rueckmeldefehler"
}
]
}
+14
View File
@@ -0,0 +1,14 @@
{
"id": "{15879A4E-D0C2-4495-83DE-46E1E462591E}",
"name": "VerbraucherEinStufig",
"type": 3,
"vendor": "Enelix",
"aliases": [
"Verbraucher 1-Stufig"
],
"parentRequirements": [],
"childRequirements": [],
"implemented": [],
"prefix": "ENELIX",
"url": "https://git.belevo.ch/ENELIX/Enelix-EMS/src/branch/develop/VerbraucherEinStufig"
}
+861
View File
@@ -0,0 +1,861 @@
<?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/EinStufigRegler.php';
use Belevo\EnelixEMS\EinStufigRegler;
use Belevo\EnelixEMS\Nachrichtenvertrag;
use Belevo\EnelixEMS\VerbraucherBasisTrait;
use Belevo\EnelixEMS\VerbraucherSchnittstelle;
class VerbraucherEinStufig extends IPSModule implements VerbraucherSchnittstelle
{
use VerbraucherBasisTrait;
private const MANAGER_MODULE_ID = '{6F771B18-59D4-4C8A-B951-3B2FE9F6A2C4}';
private const STATUS_AKTIV = 102;
private const STATUS_KONFIGURATION_UNGUELTIG = 201;
private const STATUS_SCHALTFEHLER = 202;
private const VM_UPDATE = 10603;
/** @var list<string> */
private const DIAGNOSE_VARIABLEN = [
'Istleistung',
'Leistungsquelle',
'Sollleistung',
'SollwertGueltig',
'Verfuegbar',
'AenderungMoeglich',
'Stoerung',
'Stoertext',
'Rueckmeldefehler',
];
public function Create(): void
{
parent::Create();
$this->registriereVerbraucherBasis();
$this->RegisterPropertyInteger('Nennleistung', 0);
$this->RegisterPropertyInteger('SchaltkontaktVariableID', 0);
$this->RegisterPropertyBoolean('SchaltkontaktInvertiert', false);
$this->RegisterPropertyInteger('RueckmeldungVariableID', 0);
$this->RegisterPropertyInteger('Mindestlaufzeit', 0);
$this->RegisterPropertyInteger('Umschaltabstand', 5);
$this->RegisterPropertyBoolean('DiagnosevariablenAnzeigen', false);
$this->RegisterVariableBoolean('Schaltzustand', 'Schaltzustand', '~Switch', 100);
$this->RegisterVariableInteger('Tageslaufzeit', 'Tageslaufzeit', '', 110);
$this->RegisterAttributeInteger('RegistrierterSchaltkontakt', 0);
$this->RegisterAttributeInteger('RegistrierteRueckmeldung', 0);
$this->RegisterAttributeInteger('LetzteVorgabeZeit', 0);
$this->RegisterAttributeInteger('LetzteManagerID', 0);
$this->RegisterAttributeInteger('GesperrtBis', 0);
$this->RegisterAttributeInteger('LaufzeitStandZeit', 0);
$this->RegisterAttributeString('LaufzeitTag', '');
$this->RegisterAttributeString('Leistungsangebot', '[0]');
$this->RegisterAttributeBoolean('Initialisiert', false);
$this->RegisterAttributeBoolean('SchaltvorgangLaeuft', false);
$this->RegisterAttributeBoolean('SchaltpruefungAusstehend', false);
$this->RegisterAttributeBoolean('Schaltziel', false);
$this->RegisterAttributeString('Schaltfehler', '');
$this->RegisterAttributeFloat('ZustandIstleistung', 0.0);
$this->RegisterAttributeInteger('ZustandSollleistung', 0);
$this->RegisterAttributeBoolean('ZustandSollwertGueltig', false);
$this->RegisterAttributeBoolean('ZustandVerfuegbar', false);
$this->RegisterAttributeBoolean('ZustandAenderungMoeglich', false);
$this->RegisterAttributeBoolean('ZustandStoerung', false);
$this->RegisterAttributeString('ZustandStoertext', '');
$this->RegisterAttributeBoolean('ZustandSchaltzustand', false);
$this->RegisterAttributeInteger('ZustandTageslaufzeit', 0);
$this->RegisterAttributeBoolean('ZustandRueckmeldefehler', false);
$this->RegisterTimer(
'Meldezyklus',
0,
"IPS_RequestAction(\$_IPS['TARGET'], 'Melden', false);"
);
$this->RegisterTimer(
'RueckmeldungVerzoegert',
0,
"IPS_RequestAction(\$_IPS['TARGET'], 'Melden', true);"
);
$this->RegisterTimer(
'Umschaltfreigabe',
0,
"IPS_RequestAction(\$_IPS['TARGET'], 'Umschaltfreigabe', 0);"
);
$this->RegisterTimer(
'VorgabeTimeout',
0,
"IPS_RequestAction(\$_IPS['TARGET'], 'VorgabeTimeout', 0);"
);
$this->RegisterTimer(
'Tagesplanung',
0,
"IPS_RequestAction(\$_IPS['TARGET'], 'Tagesplanung', 0);"
);
}
public function ApplyChanges(): void
{
parent::ApplyChanges();
$this->aktualisiereVariablen();
$this->registriereMeldungen();
try {
$this->pruefeKonfiguration();
} catch (Throwable $fehler) {
$this->deaktiviereTimer();
$this->setzeZustand('Verfuegbar', false);
$this->setzeZustand('AenderungMoeglich', false);
$this->setzeStoerung($fehler->getMessage(), false);
$this->SetStatus(self::STATUS_KONFIGURATION_UNGUELTIG);
$this->protokolliere('Konfiguration', $fehler->getMessage());
return;
}
$this->SetTimerInterval('Meldezyklus', $this->ReadPropertyInteger('Meldeintervall') * 1000);
$this->initialisiereZustand();
$this->aktualisiere(true);
}
public function MessageSink($zeitstempel, $senderID, $nachricht, $daten): void
{
if ((int) $nachricht !== self::VM_UPDATE || $this->ReadAttributeBoolean('SchaltvorgangLaeuft')) {
return;
}
$relevanteIDs = array_filter([
$this->ReadPropertyInteger('SchaltkontaktVariableID'),
$this->ReadPropertyInteger('RueckmeldungVariableID'),
]);
if (in_array((int) $senderID, $relevanteIDs, true)) {
$this->aktualisiere(true);
}
}
public function RequestAction($ident, $wert): void
{
switch ($ident) {
case 'Aktiv':
$this->SetValue('Aktiv', (bool) $wert);
if (!(bool) $wert) {
$this->setzeZustand('SollwertGueltig', false);
$this->SetTimerInterval('VorgabeTimeout', 0);
}
$this->aktualisiere(true);
return;
case 'Melden':
if ((bool) $wert) {
$this->SetTimerInterval('RueckmeldungVerzoegert', 0);
}
$this->aktualisiere(false);
$this->sendeVerbraucherdaten();
return;
case 'Umschaltfreigabe':
case 'VorgabeTimeout':
case 'Tagesplanung':
$this->SetTimerInterval((string) $ident, 0);
$this->aktualisiere(true);
return;
case 'ManagerdatenEmpfangen':
if (!is_string($wert)) {
throw new InvalidArgumentException('Managerdaten muessen als JSON uebergeben werden.');
}
$daten = json_decode($wert, true, 512, JSON_THROW_ON_ERROR);
if (!is_array($daten)) {
throw new InvalidArgumentException('Managerdaten muessen ein JSON-Objekt sein.');
}
$this->ManagerdatenEmpfangen($daten);
return;
}
throw new InvalidArgumentException('Unbekannte Aktion: ' . $ident);
}
/** @param array<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->aktualisiere(false);
$sollleistung = $daten['Sollleistung_W'];
if (!in_array($sollleistung, $this->leseLeistungsangebot(), true)) {
throw new InvalidArgumentException(
'Sollleistung_W liegt nicht im aktuell gemeldeten Leistungsangebot.'
);
}
$this->WriteAttributeInteger('LetzteManagerID', $managerID);
$this->WriteAttributeInteger('LetzteVorgabeZeit', time());
$this->setzeZustand('Sollleistung', $sollleistung);
$this->setzeZustand('SollwertGueltig', true);
$this->planeVorgabeTimeout(time());
$this->aktualisiere(false);
$this->planeMeldung();
}
private function aktualisiere(bool $meldungPlanen): void
{
try {
$jetzt = time();
$this->aktualisiereTageslaufzeit($jetzt);
$vorherigerSchaltzustand = (bool) $this->leseZustand('Schaltzustand');
$schaltzustand = $this->leseSchaltzustand();
if ($schaltzustand !== $vorherigerSchaltzustand) {
$this->setzeSchaltzustand($schaltzustand);
if ($this->ReadAttributeBoolean('Initialisiert')
&& !$this->ReadAttributeBoolean('SchaltpruefungAusstehend')
) {
$this->starteUmschaltsperre($jetzt);
}
}
$this->pruefeAusstehendenSchaltvorgang($schaltzustand, $jetzt);
$this->pruefeVorgabeTimeout($jetzt);
if ((bool) $this->leseZustand('SollwertGueltig')
&& !in_array(
(int) $this->leseZustand('Sollleistung'),
[0, $this->ReadPropertyInteger('Nennleistung')],
true
)
) {
$this->setzeZustand('SollwertGueltig', false);
}
$aktiv = (bool) $this->GetValue('Aktiv');
$fehlerfrei = $this->ReadAttributeString('Schaltfehler') === ''
&& !(bool) $this->leseZustand('Rueckmeldefehler');
$verfuegbar = $aktiv && $fehlerfrei;
$mindestlaufzeitErzwungen = $verfuegbar
&& $this->mussMindestlaufzeitErzwingen($jetzt);
$gesperrt = $jetzt < $this->ReadAttributeInteger('GesperrtBis');
$ziel = 0;
if ($verfuegbar) {
if ($mindestlaufzeitErzwungen) {
$ziel = $this->ReadPropertyInteger('Nennleistung');
} elseif ((bool) $this->leseZustand('SollwertGueltig')) {
$ziel = (int) $this->leseZustand('Sollleistung');
}
}
$istleistung = $this->leistungFuerSchaltzustand($schaltzustand);
if ($ziel !== $istleistung && (!$aktiv || (!$gesperrt && $fehlerfrei))) {
$this->schalte($ziel > 0, $jetzt);
$schaltzustand = $this->leseSchaltzustand();
$this->setzeSchaltzustand($schaltzustand);
$istleistung = $this->leistungFuerSchaltzustand($schaltzustand);
$gesperrt = $jetzt < $this->ReadAttributeInteger('GesperrtBis');
}
$this->setzeZustand('Sollleistung', $ziel);
$this->setzeZustand('Verfuegbar', $verfuegbar);
$aenderungMoeglich = $verfuegbar
&& !$mindestlaufzeitErzwungen
&& !$gesperrt
&& !$this->ReadAttributeBoolean('SchaltpruefungAusstehend');
$this->setzeZustand('AenderungMoeglich', $aenderungMoeglich);
$angebot = EinStufigRegler::leistungsangebot(
$this->ReadPropertyInteger('Nennleistung'),
$istleistung,
$verfuegbar,
$aenderungMoeglich,
$mindestlaufzeitErzwungen
);
$this->WriteAttributeString('Leistungsangebot', json_encode($angebot, JSON_THROW_ON_ERROR));
$this->aktualisiereStoerung();
$this->planeTimer($jetzt);
$this->SetStatus(
$this->ReadAttributeString('Schaltfehler') === ''
&& !(bool) $this->leseZustand('Rueckmeldefehler')
? self::STATUS_AKTIV
: self::STATUS_SCHALTFEHLER
);
if ($meldungPlanen) {
$this->planeMeldung();
}
} catch (Throwable $fehler) {
$this->WriteAttributeString('Schaltfehler', $fehler->getMessage());
$this->setzeZustand('Verfuegbar', false);
$this->setzeZustand('AenderungMoeglich', false);
$this->setzeStoerung($fehler->getMessage(), false);
$this->SetStatus(self::STATUS_SCHALTFEHLER);
$this->protokolliere('Aktualisierung', $fehler->getMessage());
}
}
private function schalte(bool $einschalten, int $jetzt): void
{
$kontaktID = $this->ReadPropertyInteger('SchaltkontaktVariableID');
$aktorwert = $einschalten !== $this->ReadPropertyBoolean('SchaltkontaktInvertiert');
$this->WriteAttributeBoolean('Schaltziel', $einschalten);
$this->WriteAttributeBoolean('SchaltpruefungAusstehend', true);
$this->WriteAttributeBoolean('SchaltvorgangLaeuft', true);
try {
RequestAction($kontaktID, $aktorwert);
} catch (Throwable $fehler) {
$this->WriteAttributeBoolean('SchaltpruefungAusstehend', false);
throw new RuntimeException('Schaltkontakt konnte nicht gesetzt werden: ' . $fehler->getMessage());
} finally {
$this->WriteAttributeBoolean('SchaltvorgangLaeuft', false);
}
$this->starteUmschaltsperre($jetzt);
$istzustand = $this->leseSchaltzustand();
if ($istzustand === $einschalten) {
$this->WriteAttributeBoolean('SchaltpruefungAusstehend', false);
$this->setzeZustand('Rueckmeldefehler', false);
} elseif ($this->ReadPropertyInteger('RueckmeldungVariableID') <= 0) {
$this->WriteAttributeBoolean('SchaltpruefungAusstehend', false);
throw new RuntimeException('Schaltkontakt hat den angeforderten Zustand nicht uebernommen.');
} elseif ($this->ReadPropertyInteger('Umschaltabstand') === 0) {
$this->WriteAttributeBoolean('SchaltpruefungAusstehend', false);
$this->setzeZustand('Rueckmeldefehler', true);
}
$this->WriteAttributeString('Schaltfehler', '');
$this->protokolliere('Lastwechsel', ['Eingeschaltet' => $einschalten]);
}
private function pruefeAusstehendenSchaltvorgang(bool $schaltzustand, int $jetzt): void
{
if (!$this->ReadAttributeBoolean('SchaltpruefungAusstehend')) {
if ((bool) $this->leseZustand('Rueckmeldefehler')
&& $schaltzustand === $this->ReadAttributeBoolean('Schaltziel')
) {
$this->setzeZustand('Rueckmeldefehler', false);
}
return;
}
if ($schaltzustand === $this->ReadAttributeBoolean('Schaltziel')) {
$this->WriteAttributeBoolean('SchaltpruefungAusstehend', false);
$this->setzeZustand('Rueckmeldefehler', false);
return;
}
if ($jetzt >= $this->ReadAttributeInteger('GesperrtBis')) {
$this->WriteAttributeBoolean('SchaltpruefungAusstehend', false);
$this->setzeZustand('Rueckmeldefehler', true);
}
}
private function starteUmschaltsperre(int $jetzt): void
{
$gesperrtBis = $jetzt + $this->ReadPropertyInteger('Umschaltabstand');
$this->WriteAttributeInteger('GesperrtBis', $gesperrtBis);
}
private function aktualisiereTageslaufzeit(int $jetzt): void
{
$werte = EinStufigRegler::aktualisiereTageslaufzeit(
$this->ReadAttributeString('LaufzeitTag'),
(int) $this->leseZustand('Tageslaufzeit'),
$this->ReadAttributeInteger('LaufzeitStandZeit'),
$jetzt,
(bool) $this->leseZustand('Schaltzustand'),
$this->zeitzone()
);
$this->WriteAttributeString('LaufzeitTag', $werte['Tag']);
$this->WriteAttributeInteger('LaufzeitStandZeit', $werte['StandZeit']);
$this->setzeZustand('Tageslaufzeit', $werte['Sekunden']);
}
private function mussMindestlaufzeitErzwingen(int $jetzt): bool
{
return EinStufigRegler::mussMindestlaufzeitErzwingen(
(int) $this->leseZustand('Tageslaufzeit'),
$this->ReadPropertyInteger('Mindestlaufzeit'),
EinStufigRegler::sekundenBisTagesende($jetzt, $this->zeitzone())
);
}
private function pruefeVorgabeTimeout(int $jetzt): void
{
if (!(bool) $this->leseZustand('SollwertGueltig')) {
return;
}
$letzteVorgabe = $this->ReadAttributeInteger('LetzteVorgabeZeit');
if ($letzteVorgabe <= 0
|| $jetzt - $letzteVorgabe >= $this->ReadPropertyInteger('VorgabeTimeout')
) {
$this->setzeZustand('SollwertGueltig', false);
$this->SetTimerInterval('VorgabeTimeout', 0);
}
}
private function planeTimer(int $jetzt): void
{
$gesperrtBis = $this->ReadAttributeInteger('GesperrtBis');
$this->SetTimerInterval(
'Umschaltfreigabe',
$gesperrtBis > $jetzt ? max(1, ($gesperrtBis - $jetzt) * 1000) : 0
);
$this->planeVorgabeTimeout($jetzt);
$bisTagesende = EinStufigRegler::sekundenBisTagesende($jetzt, $this->zeitzone());
$naechstePruefung = EinStufigRegler::sekundenBisNaechsteTagespruefung(
(int) $this->leseZustand('Tageslaufzeit'),
$this->ReadPropertyInteger('Mindestlaufzeit'),
$bisTagesende,
(bool) $this->leseZustand('Schaltzustand')
);
$this->SetTimerInterval('Tagesplanung', $naechstePruefung * 1000);
}
private function planeVorgabeTimeout(int $jetzt): void
{
if (!(bool) $this->leseZustand('SollwertGueltig')) {
$this->SetTimerInterval('VorgabeTimeout', 0);
return;
}
$ablauf = $this->ReadAttributeInteger('LetzteVorgabeZeit')
+ $this->ReadPropertyInteger('VorgabeTimeout');
$this->SetTimerInterval('VorgabeTimeout', max(1, ($ablauf - $jetzt) * 1000));
}
private function planeMeldung(): void
{
$this->SetTimerInterval('RueckmeldungVerzoegert', 100);
}
private function initialisiereZustand(): void
{
$jetzt = time();
$schaltzustand = $this->leseSchaltzustand();
if (!$this->ReadAttributeBoolean('Initialisiert')) {
$this->WriteAttributeString('LaufzeitTag', date('Y-m-d', $jetzt));
$this->WriteAttributeInteger('LaufzeitStandZeit', $jetzt);
$this->setzeZustand('Tageslaufzeit', 0);
$this->setzeZustand('SollwertGueltig', false);
$this->setzeZustand('Rueckmeldefehler', false);
$this->WriteAttributeBoolean('Initialisiert', true);
}
$this->setzeSchaltzustand($schaltzustand);
}
private function setzeSchaltzustand(bool $eingeschaltet): void
{
$this->setzeZustand('Schaltzustand', $eingeschaltet);
$this->setzeZustand('Istleistung', (float) $this->leistungFuerSchaltzustand($eingeschaltet));
$this->setzeZustand('Leistungsquelle', Nachrichtenvertrag::LEISTUNGSQUELLE_BERECHNET);
}
private function leseSchaltzustand(): bool
{
$rueckmeldungID = $this->ReadPropertyInteger('RueckmeldungVariableID');
if ($rueckmeldungID > 0) {
return (bool) GetValue($rueckmeldungID);
}
$kontaktwert = (bool) GetValue($this->ReadPropertyInteger('SchaltkontaktVariableID'));
return $kontaktwert !== $this->ReadPropertyBoolean('SchaltkontaktInvertiert');
}
private function leistungFuerSchaltzustand(bool $eingeschaltet): int
{
return $eingeschaltet ? $this->ReadPropertyInteger('Nennleistung') : 0;
}
private function aktualisiereStoerung(): void
{
$texte = [];
if ($this->ReadAttributeString('Schaltfehler') !== '') {
$texte[] = $this->ReadAttributeString('Schaltfehler');
}
if ((bool) $this->leseZustand('Rueckmeldefehler')) {
$texte[] = 'Schaltzustands-Rueckmeldung entspricht nicht dem angeforderten Zustand.';
}
$this->setzeStoerung(implode("\n", $texte), $texte === []);
}
private function setzeStoerung(string $text, bool $fehlerfrei): void
{
$this->setzeZustand('Stoerung', !$fehlerfrei);
$this->setzeZustand('Stoertext', $text);
}
private function sendeVerbraucherdaten(): void
{
foreach ($this->zugeordneteManagerIDs() as $managerID) {
$daten = $this->baueVerbraucherdaten($managerID);
Nachrichtenvertrag::pruefeVerbraucherdaten($daten);
try {
IPS_RequestAction(
$managerID,
'VerbraucherdatenEmpfangen',
json_encode($daten, JSON_THROW_ON_ERROR)
);
} catch (Throwable $fehler) {
$this->protokolliere('Managerkommunikation', $fehler->getMessage());
}
}
}
/** @return array<string, mixed> */
private function baueVerbraucherdaten(int $managerID): array
{
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->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' => 'Schaltzustand',
'Art' => 'Status',
'Wert' => (bool) $this->leseZustand('Schaltzustand'),
'Einheit' => '',
],
[
'Kennung' => 'Tageslaufzeit_s',
'Art' => 'Istwert',
'Wert' => (int) $this->leseZustand('Tageslaufzeit'),
'Einheit' => 's',
],
[
'Kennung' => 'Rueckmeldefehler',
'Art' => 'Stoerung',
'Wert' => (bool) $this->leseZustand('Rueckmeldefehler'),
'Einheit' => '',
'Text' => (bool) $this->leseZustand('Rueckmeldefehler')
? 'Schaltzustands-Rueckmeldung entspricht nicht dem angeforderten Zustand.'
: '',
],
],
];
}
/** @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'] as $property) {
if ($this->ReadPropertyInteger($property) <= 0) {
throw new InvalidArgumentException($property . ' muss groesser als 0 sein.');
}
}
if ($this->ReadPropertyInteger('Nennleistung') <= 0) {
throw new InvalidArgumentException('Nennleistung muss groesser als 0 sein.');
}
if ($this->ReadPropertyInteger('Mindestlaufzeit') < 0
|| $this->ReadPropertyInteger('Mindestlaufzeit') > 86400
) {
throw new InvalidArgumentException('Mindestlaufzeit muss zwischen 0 und 86400 Sekunden liegen.');
}
if ($this->ReadPropertyInteger('Umschaltabstand') < 0) {
throw new InvalidArgumentException('Umschaltabstand darf nicht negativ sein.');
}
$kontaktID = $this->ReadPropertyInteger('SchaltkontaktVariableID');
$this->pruefeBooleanVariable($kontaktID, 'Schaltkontakt', true);
$rueckmeldungID = $this->ReadPropertyInteger('RueckmeldungVariableID');
if ($rueckmeldungID > 0) {
$this->pruefeBooleanVariable($rueckmeldungID, 'Rueckmeldung', false);
}
}
private function pruefeBooleanVariable(int $variableID, string $bezeichnung, bool $aktionErforderlich): void
{
if ($variableID <= 0 || !IPS_VariableExists($variableID)) {
throw new InvalidArgumentException($bezeichnung . ' ist nicht eingerichtet.');
}
$variable = IPS_GetVariable($variableID);
if ((int) $variable['VariableType'] !== 0) {
throw new InvalidArgumentException($bezeichnung . ' muss eine Booleanvariable sein.');
}
if ($aktionErforderlich
&& (int) $variable['VariableAction'] <= 0
&& (int) $variable['VariableCustomAction'] <= 0
) {
throw new InvalidArgumentException($bezeichnung . ' benoetigt eine Standard- oder benutzerdefinierte Aktion.');
}
}
private function registriereMeldungen(): void
{
$alteIDs = array_unique(array_filter([
$this->ReadAttributeInteger('RegistrierterSchaltkontakt'),
$this->ReadAttributeInteger('RegistrierteRueckmeldung'),
]));
$neueIDs = array_unique(array_filter([
$this->ReadPropertyInteger('SchaltkontaktVariableID'),
$this->ReadPropertyInteger('RueckmeldungVariableID'),
]));
foreach ($alteIDs as $alteID) {
if (!in_array($alteID, $neueIDs, true)) {
$this->UnregisterMessage($alteID, self::VM_UPDATE);
}
}
foreach ($neueIDs as $neueID) {
if (IPS_VariableExists($neueID)) {
$this->RegisterMessage($neueID, self::VM_UPDATE);
}
}
$this->WriteAttributeInteger(
'RegistrierterSchaltkontakt',
$this->ReadPropertyInteger('SchaltkontaktVariableID')
);
$this->WriteAttributeInteger(
'RegistrierteRueckmeldung',
$this->ReadPropertyInteger('RueckmeldungVariableID')
);
}
private function aktualisiereVariablen(): void
{
foreach (self::DIAGNOSE_VARIABLEN as $ident) {
if ($ident === 'Leistungsquelle') {
continue;
}
$variablenID = @IPS_GetObjectIDByIdent($ident, $this->InstanceID);
if (is_int($variablenID) && $variablenID > 0 && IPS_VariableExists($variablenID)) {
$this->setzeZustand($ident, GetValue($variablenID));
}
}
if ($this->ReadPropertyBoolean('DiagnosevariablenAnzeigen')) {
$this->registriereVerbraucherDiagnose();
$this->RegisterVariableBoolean('Rueckmeldefehler', 'Rueckmeldefehler', '~Alert', 200);
foreach (self::DIAGNOSE_VARIABLEN as $ident) {
$wert = $ident === 'Leistungsquelle'
? Nachrichtenvertrag::LEISTUNGSQUELLE_BERECHNET
: $this->leseZustand($ident);
$this->setzeZustand($ident, $wert);
}
} else {
foreach (self::DIAGNOSE_VARIABLEN as $ident) {
$variablenID = @IPS_GetObjectIDByIdent($ident, $this->InstanceID);
if (is_int($variablenID) && $variablenID > 0 && IPS_VariableExists($variablenID)) {
$this->UnregisterVariable($ident);
}
}
}
}
/** @param bool|int|float|string $wert */
private function setzeZustand(string $ident, $wert): void
{
$booleanAttribute = [
'SollwertGueltig' => 'ZustandSollwertGueltig',
'Verfuegbar' => 'ZustandVerfuegbar',
'AenderungMoeglich' => 'ZustandAenderungMoeglich',
'Stoerung' => 'ZustandStoerung',
'Schaltzustand' => 'ZustandSchaltzustand',
'Rueckmeldefehler' => 'ZustandRueckmeldefehler',
];
$integerAttribute = [
'Sollleistung' => 'ZustandSollleistung',
'Tageslaufzeit' => 'ZustandTageslaufzeit',
];
$floatAttribute = [
'Istleistung' => 'ZustandIstleistung',
];
$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',
'Schaltzustand' => 'ZustandSchaltzustand',
'Rueckmeldefehler' => 'ZustandRueckmeldefehler',
];
$integerAttribute = [
'Sollleistung' => 'ZustandSollleistung',
'Tageslaufzeit' => 'ZustandTageslaufzeit',
];
$floatAttribute = [
'Istleistung' => 'ZustandIstleistung',
];
$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));
}
private function zeitzone(): DateTimeZone
{
return new DateTimeZone(date_default_timezone_get());
}
private function deaktiviereTimer(): void
{
foreach ([
'Meldezyklus',
'RueckmeldungVerzoegert',
'Umschaltfreigabe',
'VorgabeTimeout',
'Tagesplanung',
] as $timer) {
$this->SetTimerInterval($timer, 0);
}
}
/** @param mixed $daten */
private function protokolliere(string $bezeichnung, $daten): void
{
if (!$this->ReadPropertyBoolean('LoggingEin')) {
return;
}
$this->SendDebug(
$bezeichnung,
is_string($daten) ? $daten : json_encode($daten, JSON_THROW_ON_ERROR),
0
);
}
}
+230
View File
@@ -0,0 +1,230 @@
{
"elements": [
{
"type": "ExpansionPanel",
"caption": "Manager und Zeitverhalten",
"expanded": true,
"items": [
{
"type": "NumberSpinner",
"name": "PrioritaetPV",
"caption": "Priorität PV",
"minimum": 0
},
{
"type": "NumberSpinner",
"name": "PrioritaetPeak",
"caption": "Priorität Peak",
"minimum": 0
},
{
"type": "NumberSpinner",
"name": "Meldeintervall",
"caption": "Meldeintervall",
"suffix": " s",
"minimum": 1
},
{
"type": "NumberSpinner",
"name": "VorgabeTimeout",
"caption": "Vorgabe-Timeout",
"suffix": " s",
"minimum": 1
},
{
"type": "NumberSpinner",
"name": "LastwechselSperrzeit",
"caption": "Mindestzeit zwischen Lastwechseln",
"suffix": " s",
"minimum": 1
}
]
},
{
"type": "ExpansionPanel",
"caption": "Speichereinstellungen",
"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": "SelectVariable",
"name": "Boilerfuehler_PT1",
"caption": "Boilerfühler"
},
{
"type": "CheckBox",
"name": "LegionellenfunktionAktiv",
"caption": "Legionellenschaltung aktiv"
},
{
"type": "NumberSpinner",
"name": "LegionellenMinimalintervallTage",
"caption": "Frühestens nach",
"suffix": " Tagen",
"minimum": 1
},
{
"type": "NumberSpinner",
"name": "LegionellenMaximalintervallTage",
"caption": "Spätestens nach",
"suffix": " Tagen",
"minimum": 1
}
]
},
{
"type": "ExpansionPanel",
"caption": "Erweiterte Speichereinstellungen",
"items": [
{
"type": "NumberSpinner",
"name": "Boilervolumen",
"caption": "Boilervolumen",
"suffix": " l",
"minimum": 1
},
{
"type": "NumberSpinner",
"name": "Hysterese",
"caption": "Temperaturhysterese",
"suffix": " K",
"minimum": 0.1,
"digits": 1
},
{
"type": "List",
"name": "Zeitplan",
"caption": "Zeitplan für Solltemperaturen",
"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": "Erweiterte sonstige Einstellungen",
"items": [
{
"type": "NumberSpinner",
"name": "TemperaturMaxAlter",
"caption": "Maximales Messwertalter",
"suffix": " s",
"minimum": 1
},
{
"type": "CheckBox",
"name": "BoilertemperaturGlaetten",
"caption": "Boilertemperatur mit PT1 glätten"
},
{
"type": "NumberSpinner",
"name": "ZeitKonstante",
"caption": "PT1-Zeitkonstante",
"suffix": " s",
"minimum": 1
},
{
"type": "CheckBox",
"name": "EinstellungenInVisu",
"caption": "Temperatursollwerte in der Visualisierung anzeigen und bedienen"
},
{
"type": "CheckBox",
"name": "DiagnosevariablenAnzeigen",
"caption": "Diagnosevariablen anlegen"
},
{
"type": "CheckBox",
"name": "LoggingEin",
"caption": "Laufendes Debug-Logging aktivieren"
}
]
}
],
"status": [
{
"code": 102,
"icon": "active",
"caption": "Aktiv"
},
{
"code": 201,
"icon": "inactive",
"caption": "Temperaturmessung fehlt oder ist veraltet"
},
{
"code": 202,
"icon": "error",
"caption": "Konfiguration ungültig"
},
{
"code": 203,
"icon": "error",
"caption": "Schaltfehler"
}
]
}
+14
View File
@@ -0,0 +1,14 @@
{
"id": "{B7C54AF4-AD7D-4FE4-B75D-203693906251}",
"name": "VerbraucherWarmwassererwaermer",
"type": 3,
"vendor": "Enelix",
"aliases": [
"Wassererwärmer"
],
"parentRequirements": [],
"childRequirements": [],
"implemented": [],
"prefix": "ENELIX",
"url": "https://git.belevo.ch/ENELIX/Enelix-EMS/src/branch/develop/Warmwassererwaermer"
}
File diff suppressed because it is too large Load Diff
+30
View File
@@ -0,0 +1,30 @@
# Obere Anschlüsse des Managers
> Status: Diskussionsentwurf. Anbieterformate und konkrete Gegenstellen sind
> noch nicht festgelegt.
Diese Anschlüsse sind optional. Sie werden im Manager konfiguriert und erzeugen
keine Abhängigkeit zwischen Enelix EMS und Enelix Utils.
| Anschluss | Aufgabe | Gegenstelle |
| --- | --- | --- |
| SDL/VGT | Zeitlich gültige Leistungsaufträge und Rückmeldungen | Optional das unabhängige Utils-Modul VGT-Schnittstelle |
| Prognose / Forecast | Vorhersage für Produktion und bei Bedarf Verbrauch | Externer Anbieter; kein Repo-Modul |
| Lizenzierung | Prüfung freigeschalteter Funktionen | Externer Lizenzdienst; kein Repo-Modul |
| Störüberwachung | Weitergabe von Zuständen und Störungen | Externer Empfänger; kein Repo-Modul |
## Gemeinsame Grundsätze
- Die Auswahl der Gegenstelle erfolgt ausschliesslich im Manager.
- Anbieterformate werden an diesem Anschluss übersetzt und gelangen nicht in
die Verbraucher-Schnittstelle.
- Jeder Anschluss meldet im Manager `NichtVerwendet`, `Verbunden` oder `Fehler`.
- Fehlende optionale Anschlüsse dürfen die lokale EMS-Grundfunktion nicht
blockieren.
## Noch festzulegen
- Anbieter und Datenformat für Prognose, Lizenzierung und Störüberwachung
- Aufbau, Gültigkeitszeitraum und Rückmeldung eines SDL-Auftrags
- Authentisierung und Verhalten bei Verbindungsunterbruch
- Welche Lizenzfehler nur Funktionen sperren und welche den Manager stoppen
+57
View File
@@ -81,3 +81,60 @@ public function VerbraucherdatenEmpfangen(array $daten): void;
- Eine aktive Stoerung wird mit `Art=Stoerung` und `Wert=true` gemeldet; eine behobene mit `false`.
- Verbraucher werden ausschliesslich im Manager zugeordnet. Der Verbraucher besitzt keine Manager-ID-Property.
## Technische Umsetzung in IP-Symcon
Der Transport erfolgt über `IPS_RequestAction` mit JSON. `MessageSink` erkennt
registrierte Änderungen; der empfangende Action-Handler prüft das Paket und
ruft danach genau eine fachliche Empfangsmethode auf. Empfang und Neuberechnung
werden intern entkoppelt, damit keine gegenseitige Endlosschleife entsteht.
Das PHP-Interface legt nur die Methode fest. Die folgenden gemeinsamen
Symcon-Datenpunkte werden durch `VerbraucherBasisTrait` registriert.
### Gemeinsame Properties aller Verbraucher
| Ident | Typ | Standard | Beschreibung |
| --- | --- | --- | --- |
| `PrioritaetPV` | Integer | `0` | Priorität ab 0; kleinere Zahl bedeutet höhere Priorität. |
| `PrioritaetPeak` | Integer | `0` | Wie PV-Priorität, ohne fachliche Obergrenze. |
| `Meldeintervall` | Integer | `10` | Vollständige Rückmeldung in Sekunden; muss grösser als 0 sein. |
| `VorgabeTimeout` | Integer | `120` | Sollwert wird nach dieser Zeit ohne Erneuerung ungültig. |
| `EinstellungenInVisu` | Boolean | `false` | Blendet lokale Einstellvariablen ein und macht sie geprüft bedienbar. |
| `LoggingEin` | Boolean | `false` | Aktiviert das laufende Diagnoseprotokoll. Module können zusätzliche Diagnosevariablen getrennt einblenden. |
### Gemeinsame Variablen aller Verbraucher
| Ident | Typ / Zugriff | Beschreibung |
| --- | --- | --- |
| `Aktiv` | Boolean / bedienbar | Lokale EMS-Freigabe; Start `false`. |
| `Istleistung` | Float / Anzeige | Aktuelle Leistung in W; nur zusammen mit `Leistungsquelle` gültig. |
| `Leistungsquelle` | Integer / Anzeige | `0` nicht vorhanden, `1` berechnet, `2` gemessen. |
| `Sollleistung` | Integer / Anzeige | Tatsächlich angenommene Vorgabe in W. |
| `SollwertGueltig` | Boolean / Anzeige | Kennzeichnet eine aktuell gültige Vorgabe. |
| `Verfuegbar` | Boolean / Anzeige | Verbraucher grundsätzlich für EMS-Steuerung verfügbar. |
| `AenderungMoeglich` | Boolean / Anzeige | Neue Vorgabe darf momentan übernommen werden. |
| `Stoerung` | Boolean / Anzeige | Mindestens ein Zustandseintrag der Art `Stoerung` ist aktiv. |
| `Stoertext` | String / Anzeige | Zusammengefasste lesbare Störbeschreibung. |
`Leistungswerte_W` und `Zustand` werden intern gehalten und direkt in die
Nachricht geschrieben. Dafür werden keine zusätzlichen Symcon-Variablen
angelegt. Der gemeinsame Zustandseintrag `Sollleistung_W` wird durch die Basis
ergänzt. Während einer Lastwechselsperre meldet ein Verbraucher
`AenderungMoeglich` als `false` und beschränkt `Leistungswerte_W` auf die aktuell
gehaltene Leistung. Separate Felder wie `Idle` oder `IdleCounter` sind nicht
Bestandteil des Vertrags.
## Zeitverhalten
- Rückmeldung nach dem Start, bei relevanten Änderungen und zusätzlich alle
`Meldeintervall` Sekunden.
- Laufende Vorgaben werden vom Manager standardmässig alle 60 Sekunden erneuert.
- Nach `VorgabeTimeout` Sekunden ist eine nicht erneuerte Vorgabe ungültig.
- Nach einem Neustart wird zuerst der Gerätezustand erfasst; eine alte Vorgabe
wird nicht ungeprüft wieder aufgenommen.
## Obere Anschlüsse des Managers
SDL/VGT, Prognose, Lizenzierung und Störüberwachung verändern den
Manager-Verbraucher-Vertrag nicht. Ihr aktueller Diskussionsstand ist in
[Obere-Anschluesse.md](Obere-Anschluesse.md) beschrieben.
@@ -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,57 @@
# ADR 0002: Verbraucher 1-Stufig arbeitet ereignisbasiert
## Status
Akzeptiert.
## Kontext
Das Enelix-1-Modul berechnete seinen Zustand in einem festen Intervall und
bildete die Lastwechselsperre ueber `Interval`, `IdleCounterMax` und weitere
zyklusabhaengige Zaehler ab. Dieses Verhalten passt nicht zur
ereignisbasierten Enelix-2-Kommunikation und machte reale Zeitabstaende von
mehreren Properties abhaengig.
## Entscheidung
Der neue `VerbraucherEinStufig` verwendet keine zyklische Regelberechnung.
Schaltkontakt, Rueckmeldung, Manager-Vorgabe, Freigabe, Vorgabeablauf und
Tagesplanung loesen die Regelung direkt aus.
Als einziger modulspezifischer Wert fuer die Lastwechselbegrenzung wird
`Umschaltabstand` in Sekunden registriert. Der Standardwert ist 5 Sekunden.
Das Ende der Sperre wird durch einen einmaligen Timer ausgeloest.
Das gemeinsame `Meldeintervall` bleibt erhalten, weil Vertrag `3.0`
zusaetzlich zu Ereignismeldungen eine periodische Vollmeldung fordert. Dieser
Timer ist kein Regelzyklus.
Die Tagesmindestlaufzeit wird in realen Sekunden und lokaler Symcon-Zeitzone
gezaehlt. Das Modul erzwingt den Betrieb erst zum spaetestmoeglichen Zeitpunkt,
an dem die fehlende Laufzeit vor Mitternacht noch erreicht werden kann.
## Alternativen
- Ein fester Regelzyklus wurde verworfen, weil Reaktionszeit und Sperrzeit
wieder voneinander abhaengen wuerden.
- Getrennte Ein- und Ausschaltzeiten wurden verworfen, weil sie fuer diese
Adaption nicht freigegeben sind.
- Die feste Nachtphase aus Enelix 1 wurde verworfen, weil sie weder
konfigurierbar noch Teil der Enelix-2-Spezifikation ist.
## Folgen
- Lastwechsel reagieren ohne Polling auf relevante Ereignisse.
- `Interval` und `IdleCounterMax` entfallen.
- Der Manager sieht waehrend einer Sperre nur die aktuelle Leistung und
`AenderungMoeglich=false`.
- Ein optionaler Rueckmeldekontakt muss innerhalb des Umschaltabstands folgen.
- Tageslaufzeit und Mindestlaufzeit sind unabhaengig von einer Zyklusdauer.
- Das Modul bleibt vollstaendig im Repository Enelix EMS; Enelix Utils erhaelt
keine geraetespezifische Logik.
## Offene Punkte
- Eine spaetere sperrbare Variante wird separat spezifiziert.
- Unterschiedliche Ein- und Ausschaltabstaende benoetigen eine neue
Architekturentscheidung.
+49
View File
@@ -0,0 +1,49 @@
# Migration von Boiler_x_Stufig
## Eigenschaften
| Enelix 1 | Enelix 2 | Hinweis |
| --- | --- | --- |
| `LeistungsStufen` | `LeistungsStufen` | Felder `Stufe`, `Leistung`, `Schaltkontakt_Stufe` bleiben erhalten. |
| `ZeitKonstante` | `ZeitKonstante` | PT1-Berechnung verwendet die tatsaechlich verstrichene Zeit. |
| `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` | entfaellt | Die Regelung wird ereignisbasiert ausgeloest. |
| `IdleCounterMax` | `LastwechselSperrzeit` | Zykluszaehler wird durch eine Sperrzeit in Sekunden ersetzt; Standard `5`. |
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` | entfaellt; Freigabe ueber `AenderungMoeglich` |
| `IdleCounter` | entfaellt; Sperre ueber `Leistungswerte_W` und `AenderungMoeglich` |
| `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.
+60
View File
@@ -0,0 +1,60 @@
# Migration von Verbraucher_1_Stufig
Das Enelix-1-Modul wird nicht in-place aktualisiert. Das neue Modul verwendet
eine neue Modul-ID, den Vertrag `3.0` und eine ereignisbasierte Regelung.
## Eigenschaften
| Enelix 1 | Enelix 2 | Migration |
| --- | --- | --- |
| `BoilerLeistung` | `Nennleistung` | Wert in ganzen Watt uebernehmen. |
| `Schaltkontakt1` | `SchaltkontaktVariableID` | Boolean-Aktor uebernehmen und Aktion pruefen. |
| nicht vorhanden | `SchaltkontaktInvertiert` | Nur bei umgekehrter Aktorlogik aktivieren. |
| nicht vorhanden | `RueckmeldungVariableID` | Optional eine echte Boolean-Rueckmeldung zuordnen. |
| `Mindesttlaufzeit` | `Mindestlaufzeit` | Alter Wert war in Minuten gedacht; fuer gleiches Verhalten mit 60 multiplizieren und als Sekunden eintragen. |
| `Zeit_Zwischen_Zustandswechseln` | `Umschaltabstand` | Alte Minuten nicht uebernehmen. Neuen Sekundenwert bewusst festlegen; Standard ist 5 s. |
| `Interval` | entfaellt | Keine zyklische Regelberechnung mehr. |
| `IdleCounterMax` | entfaellt | Keine zyklusbasierte Idle-Erkennung mehr. |
## Variablen und Verhalten
| Enelix 1 | Enelix 2 |
| --- | --- |
| `DailyOnTime` | `Tageslaufzeit` in echten Sekunden |
| `IstNacht` | entfaellt; Mindestlaufzeit wird am spaetestmoeglichen Start des lokalen Kalendertags erzwungen |
| `IsTimerActive` | entfaellt; interne einmalige Timer |
| `Aktuelle_Leistung` | gemeinsames `Istleistung` |
| `Power` | gemeinsames `Sollleistung` |
| `PowerSteps` | Vertragsfeld `Leistungswerte_W` |
| `PV_Prio` | Property `PrioritaetPV` |
| `Sperre_Prio` | Property `PrioritaetPeak` |
| `Is_Peak_Shaving` | entfaellt; Betriebsart liegt im Manager |
| `Leistung_Delta` | entfaellt; Leistungsverteilung liegt im Manager |
| `Idle`, `IdleCounter` | entfallen |
Die feste Enelix-1-Nachtzeit von 22:00 bis 07:00 wird nicht uebernommen.
Enelix 2 plant nur den Zeitpunkt, ab dem die verbleibende Mindestlaufzeit vor
dem lokalen Tagesende zwingend eingeschaltet werden muss.
## Inbetriebnahme
1. Bestehende Instanzkonfiguration und aktuelle Aktorlogik dokumentieren.
2. Neue Instanz **Verbraucher 1-Stufig** anlegen.
3. Leistung, Aktor, Invertierung und optional Rueckmeldung konfigurieren.
4. Mindestlaufzeit von Minuten in Sekunden umrechnen.
5. `Umschaltabstand` neu in Sekunden festlegen; nicht den alten Minutenwert
kopieren.
6. Neue Instanz im Enelix-2-Manager aktiv zuordnen.
7. Bei ausgeschalteter lokaler EMS-Freigabe Ein/Aus und Rueckmeldung
beaufsichtigt pruefen.
8. Alte Instanz erst deaktivieren, danach `Aktiv` an der neuen Instanz
einschalten.
9. Mindestens einen Timeout, einen verhinderten Lastwechsel und einen
Tageswechsel kontrollieren.
## Rueckkehr
Solange die alte Instanz nicht geloescht wurde, kann zurueckgekehrt werden,
indem die neue Instanz im Manager deaktiviert und ausgeschaltet wird. Danach
darf die alte Instanz wieder aktiviert werden. Beide Module duerfen nie
gleichzeitig denselben Aktor steuern.
+59
View File
@@ -0,0 +1,59 @@
# Batterie
> Status: Diskussionsentwurf. Positiv bedeutet Laden, negativ Entladen.
Die Auswahl des Batterietyps bestimmt den Adapter und die sichtbaren
Anschlussfelder. Ohne Logging bleibt von den zusätzlichen Variablen nur
`Ladestatus` sichtbar.
## Zusätzliche Variablen
| Ident | Typ / Zugriff | Beschreibung |
| --- | --- | --- |
| `Ladestatus` | Integer / Anzeige | `0` unbekannt, `1` ruhend, `2` laden, `3` entladen. |
| `Ladezustand` | Float / Logging | Physischer Ladezustand in %. |
| `LadezustandGueltig` | Boolean / Logging | Gültigkeit unabhängig von der Leistungsmessung. |
| `Steuerungsmodus` | Integer / lokal bedienbar | `0` Batterie, `1` Manager, `2` Virtuelle Batterie führt. |
| `NotnachladungAktiv` | Boolean / Logging | Aus Ladezustand ermittelter lokaler Bedarf. |
| `LadeleistungVerfuegbar` | Float / Logging | Aktuell nutzbare Ladeleistung in W. |
| `EntladeleistungVerfuegbar` | Float / Logging | Aktuell nutzbare Entladeleistung als positiver Betrag in W. |
| `MaxLadeleistung` | Float / lokal bedienbar | Wirksamer Parameter in W. |
| `MaxEntladeleistung` | Float / lokal bedienbar | Wirksamer Parameter in W. |
| `Mindestladezustand` | Float / lokal bedienbar | Wirksamer Parameter in %. |
| `Maximalladezustand` | Float / lokal bedienbar | Wirksamer Parameter in %. |
| `Nachladeschwelle` | Float / lokal bedienbar | Wirksamer Parameter in %. |
| `Nachladehysterese` | Float / lokal bedienbar | Wirksamer Parameter in %. |
| `Notnachladeleistung` | Float / lokal bedienbar | `0` deaktiviert; sonst Leistung in W. |
## Zusätzliche Properties
| Ident | Typ | Standard / Beschreibung |
| --- | --- | --- |
| `MaxLadeleistung` | Float | `0*` W; einzige einstellbare obere Ladegrenze. |
| `MaxEntladeleistung` | Float | `0` W; einzige einstellbare obere Entladegrenze. |
| `Mindestladezustand` | Float | `0*` %; Bereich 0–100. |
| `Maximalladezustand` | Float | `100` %; grösser als Mindestladezustand. |
| `Nachladeschwelle` | Float | `0*` %; zwischen Mindest- und Maximalladezustand. |
| `Nachladehysterese` | Float | `2` %; Schwelle plus Hysterese höchstens Maximalladezustand. |
| `Notnachladeleistung` | Float | `0` W; sonst höchstens MaxLadeleistung. |
| `Batterietyp` | Auswahl | `Unkonfiguriert`, `Herstellerunabhaengig`, `Goodwe`, `SolarEdge`, `SigEnergy`. |
| `BatterieInstanzID` | Integer | `0`; für kompatible Herstelleradapter. |
| `LadezustandVariableID` | Integer | `0`; nur herstellerunabhängig. |
| `IstleistungVariableID` | Integer | `0`; nur herstellerunabhängig. |
| `SollleistungVariableID` | Integer | `0`; nur herstellerunabhängig, vorzeichenbehaftet in W. |
| `LadezustandMaxAlter` | Integer | `60` s; Aktualität des Ladezustands. |
| `VirtuelleBatterieID` | Integer | `0`; Pflicht bei Steuerungsmodus 2. |
Anschlussart, Batteriespannung, Gerätezuordnung, Leistungsschritt,
Sollwertquelle und externe Sollwerte entfallen.
## Zustand
`Ladezustand_Prozent` als Istwert; `Steuerungsmodus` und
`NotnachladungAktiv` als Status; `LadezustandFehlt` und `Geraetefehler` als
Störungen.
## Offene Punkte
- Michis konkrete Fehlerliste nachliefern und gegen den Entwurf prüfen.
- Datenpunkte und Leistungsgrenzen der drei Herstelleradapter verifizieren.
+35
View File
@@ -0,0 +1,35 @@
# Easee Gateway
> Status: Diskussionsentwurf. Kommunikationsmodul, kein Verbraucher und keine
> Verwendung der Verbraucherbasis.
Das bestehende Gateway wird übernommen. Anzeigen werden deutsch; technische
Alt-Idents bleiben zur Kompatibilität erhalten. Eine passende vorhandene
Verbindung soll bei der Instanziierung wiederverwendet werden.
## Variablen
| Technischer Ident / Anzeige | Typ / Zugriff | Beschreibung |
| --- | --- | --- |
| `Connected` / Verbunden | Boolean / Anzeige | SignalR-Verbindungsstatus. |
| `SubscriptionCount` / Angemeldete Ladestationen | Integer / Anzeige | Anzahl registrierter Geräte. |
| `LastError` / Letzter Fehler | String / Anzeige | Diagnose ohne Zugangsdaten. |
## Properties
| Technischer Ident / Anzeige | Typ | Standard / Beschreibung |
| --- | --- | --- |
| `Active` / Aktiv | Boolean | `true`; Verbindungsbetrieb, keine Ladefreigabe. |
| `Username` / Benutzername | String | leer; Easee-Konto. |
| `Password` / Passwort | String | leer; vertraulich. |
| `VerifyCertificate` / Zertifikat prüfen | Boolean | `true`; TLS-Prüfung. |
## Verhalten
Nach einer Wiederverbindung werden Gerätezustände erst nach neuer gültiger
Rückmeldung verwendet. Das Gateway sendet keine EMS-Verbrauchermeldung.
## Offene Punkte
- Instanziierung und Wiederverwendung bestehender Verbindungen testen.
- Aktuelle Easee-Endpunkte und Ereignisfelder vor Übernahme verifizieren.
+41
View File
@@ -0,0 +1,41 @@
# Ladestation Gateway
> Status: Diskussionsentwurf. Eigenständiges Verbrauchermodul mit zugeordnetem
> Easee Gateway; keine gemeinsame Ladestations-Basisklasse.
## Zusätzliche Variablen
| Ident | Typ / Zugriff | Beschreibung |
| --- | --- | --- |
| `FahrzeugVerbunden` | Boolean / Anzeige | Nur bei gültigem Gatewaystatus aussagekräftig. |
| `Fahrzeugstatus` | Integer / Anzeige | `0` unbekannt, `1` nicht verbunden, `2` bereit, `3` lädt, `4` voll, `5` Fehler. |
| `Ladestrom` | Float / Anzeige | Aktueller Ladestrom in A. |
| `Phasenzahl` | Integer / Anzeige | `0` unbekannt, `1` einphasig, `3` dreiphasig. |
| `Ladefreigabe` | Boolean / lokal bedienbar | Lokale Ladeerlaubnis zusätzlich zu `Aktiv`. |
| `Solarladen` | Boolean / lokal bedienbar | Überschussorientiertes Laden ein/aus. |
## Zusätzliche Properties
| Ident | Typ | Standard / Beschreibung |
| --- | --- | --- |
| `Ladefreigabe` | Boolean | `false`. |
| `Solarladen` | Boolean | `true`. |
| `MaximalerLadestrom` | Float | `0*` A; Installations- und Gerätegrenze. |
| `Ladestromschritt` | Float | `1` A; muss von Easee unterstützt sein. |
| `MindestEinzeit` | Integer | `0` s. |
| `MindestAuszeit` | Integer | `0` s. |
| `GatewayID` | Integer | `0`; kompatibles Easee Gateway, erforderlich. |
| `Ladestationskennung` | String | leer; eindeutige Station/Seriennummer im Gateway. |
Es gibt hier keine Zugangsdaten; sie liegen ausschliesslich im Easee Gateway.
Ladefreigabe und Solarladen werden über `EinstellungenInVisu` eingeblendet.
## Zustand
`Fahrzeugstatus`, `FahrzeugVerbunden`, `Ladefreigabe`, `Solarladen`,
`Ladestrom_A`, `Ladefehler` und `Gatewayfehler`.
## Offene Punkte
- Reaktion aller zugeordneten Stationen bei Gatewayausfall im Praxistest festlegen.
- Lokalen Mindestladebedarf bei deaktiviertem Solarladen genau festlegen.
@@ -0,0 +1,46 @@
# Ladestation Stand-Alone
> Status: Diskussionsentwurf. Eigenständiges Verbrauchermodul mit direkter
> Geräteanbindung; keine Beziehung zur Ladestation Gateway.
## Zusätzliche Variablen
| Ident | Typ / Zugriff | Beschreibung |
| --- | --- | --- |
| `FahrzeugVerbunden` | Boolean / Anzeige | Nur bei gültigem Gerätestatus aussagekräftig. |
| `Fahrzeugstatus` | Integer / Anzeige | `0` unbekannt, `1` nicht verbunden, `2` bereit, `3` lädt, `4` voll, `5` Fehler. |
| `Ladestrom` | Float / Anzeige | Aktueller Ladestrom in A. |
| `Phasenzahl` | Integer / Anzeige | `0` unbekannt, `1` einphasig, `3` dreiphasig. |
| `Ladefreigabe` | Boolean / lokal bedienbar | Lokale Ladeerlaubnis zusätzlich zu `Aktiv`. |
| `Solarladen` | Boolean / lokal bedienbar | Überschussorientiertes Laden ein/aus. |
## Zusätzliche Properties
| Ident | Typ | Standard / Beschreibung |
| --- | --- | --- |
| `Ladefreigabe` | Boolean | `false`. |
| `Solarladen` | Boolean | `true`. |
| `MaximalerLadestrom` | Float | `0*` A; Installations- und Gerätegrenze. |
| `Ladestromschritt` | Float | `1` A; muss vom Gerät unterstützt sein. |
| `MindestEinzeit` | Integer | `0` s. |
| `MindestAuszeit` | Integer | `0` s. |
| `Geraetetyp` | Auswahl | `Unkonfiguriert`, `GoEAlt`, `GoEGemini` oder `SmartMePico`. |
| `Geraeteadresse` | String | leer; nur für lokale Adapter. |
| `GeraeteID` | String | leer; nur falls der Adapter eine ID benötigt. |
| `Seriennummer` | String | leer; nur falls benötigt. |
| `Benutzername` | String | leer; nur falls Anmeldung benötigt. |
| `Passwort` | String | leer; vertraulich und nie als Variable/Diagnose anzeigen. |
Minimaler Ladestrom, frühere Umschaltabstände, Benutzervariable und externe
Sollwert-Variable entfallen. Ladefreigabe und Solarladen erscheinen nur bei
aktivem `EinstellungenInVisu` als bedienbare Variablen.
## Zustand
`Fahrzeugstatus`, `FahrzeugVerbunden`, `Ladefreigabe`, `Solarladen` und
`Ladestrom_A` sowie `Ladefehler` und gerätespezifische Fehler.
## Offene Punkte
- Direkte Adapter und ihre noch gültigen API-Routen bei der Implementierung prüfen.
- Lokalen Mindestladebedarf bei deaktiviertem Solarladen genau festlegen.
+111
View File
@@ -0,0 +1,111 @@
# Manager
> Status: Implementiert. Führt Hauptmanager und Peakshaving zusammen.
Der Manager liest die Netzleistung, verwaltet ausschliesslich die ausgewählten
Verbraucher und verteilt Leistung im PV- oder Peak-Betrieb. Die Prioritäten
kommen aus den Verbrauchermeldungen.
## Variablen
Ohne angelegte Diagnosevariablen existieren nur `Aktiv`, `Betriebsart` und `Netzleistung`.
| Ident | Typ / Zugriff | Beschreibung |
| --- | --- | --- |
| `Aktiv` | Boolean / bedienbar | Regelung ein/aus; Start `false`. |
| `Betriebsart` | String / Anzeige | `Inaktiv`, `PV` oder `Peak`. |
| `Netzleistung` | Float / Anzeige | Aktuelle Netzleistung in W; positiv Bezug, negativ Einspeisung. |
| `NetzleistungGueltig` | Boolean / Logging | Messquelle vorhanden und aktuell. |
| `WirksameLastspitzengrenze` | Float / Logging | Aktuelle feste oder monatliche Grenze in W. |
| `Verteilbudget` | Float / Logging | Aktuell verfügbares Budget in W. |
| `VerbraucherAnzahl` | Integer / Logging | Anzahl zugeordneter Verbraucher. |
| `VerbraucherVerfuegbar` | Integer / Logging | Anzahl aktuell verfügbarer Verbraucher. |
| `Verbraucherstatus` | String/JSON / Logging | Letzte vollständige Meldungen. |
| `Prognosestatus` | String / Logging | `NichtVerwendet`, `Verbunden` oder `Fehler`. |
| `SDLStatus` | String / Logging | Status des SDL/VGT-Anschlusses. |
| `Lizenzstatus` | String / Logging | Status der Lizenzprüfung. |
| `Stoerueberwachungsstatus` | String / Logging | Status der externen Störüberwachung. |
| `Sammelstoerung` | Boolean / Logging | Eigene und weitergeleitete Störungen. |
| `Stoertext` | String / Logging | Lesbare Sammelmeldung. |
## Properties
| Ident | Typ | Standard / Beschreibung |
| --- | --- | --- |
| `Rolle` | Auswahl | `Alleine`; alternativ `Hauptmanager` oder `Untermanager`. |
| `NetzleistungVariableID` | Integer | `0*`; gültige Messquelle vor Regelstart. |
| `Netzleistungsfaktor` | Float | `1`; auf W und die vereinbarte Vorzeichenrichtung normieren. |
| `MesswertMaxAlter` | Integer | `60` s; auf echte Messaktualisierung bezogen. |
| `VerbraucherZuordnung` | String/JSON | `[]`; manuelle Auswahl aus Instanz-ID und Aktiv-Status. |
| `AutomatischeVerbraucherZuordnung` | String/JSON | `[]`; Auswahl aus automatisch gefundenen Verbrauchern. |
| `AutomatischeSuche` | Boolean | `true`; zeigt gefundene Verbraucher zur gezielten Auswahl. |
| `SuchbereichID` | Integer | `0`; optionaler Suchbereich für die automatische Suche. |
| `KeepAlive` | Integer | `60` s; erneuert laufende Vorgaben zyklisch. |
| `VerbraucherTimeout` | Integer | `60` s; erkennt ausgebliebene Verbrauchermeldungen. |
| `Lastspitzenmodus` | Auswahl | `Aus`; alternativ `Konstant` oder `Monatlich`. |
| `Lastspitzengrenze` | Float | Feste Grenze in W für Modus `Konstant`. |
| `Monatsgrenzen` | String/JSON | Editierbare Liste mit zwölf Monatswerten in W. |
| `SollwertSolarladen` | Float | `0` W; gewünschte Netzleistung im Solarladebetrieb. |
| `Umschaltdifferenz` | Float | `5` %; Mindestdifferenz der berechneten Sollleistungen vor Umschaltung. |
| `PrognoseAnschluss` | String/JSON | Optionaler Forecast-Anschluss. |
| `SDLAnschluss` | String/JSON | Optionaler SDL/VGT-Anschluss. |
| `LizenzAnschluss` | String/JSON | Optionaler Lizenzanschluss. |
| `StoermeldeAnschluss` | String/JSON | Optionaler Anschluss zur Störüberwachung. |
| `DiagnosevariablenAnzeigen` | Boolean | `false`; zusätzliche Diagnosevariablen anlegen oder entfernen. |
| `LoggingEin` | Boolean | `false`; laufende Meldungen im Debug-Fenster ausgeben. |
Es gibt keine Sollwertquellenauswahl und keine zweite Prioritätseinstellung im
Manager. Im Modus `Aus` bleibt Solarladen aktiv, nur die Peak-Begrenzung entfällt.
Die Monatsgrenzen werden in den Grundeinstellungen über eine Schaltfläche
ein- und ausgeblendet. Die automatische Verbrauchersuche kann dort erneut
ausgeführt werden, ohne andere ungespeicherte Formulareingaben zu verlieren.
Die vier optionalen oberen Anschlüsse besitzen jeweils einen eigenen Bereich;
die Lizenzierung steht an erster Stelle des Formulars.
## Verteilalgorithmus
Der Manager bildet das Verteilbudget aus der aktuellen Netzleistung, dem Ziel
der aktiven Betriebsart und der aktuellen Leistung aller frisch gemeldeten
Verbraucher. Eine gültige Istleistung wird bevorzugt; fehlt sie, bleibt der im
Zustand bestätigte Sollwert konservativ reserviert.
Nicht verfügbare, nicht änderbare oder aktuell angebotlose Verbraucher behalten
ihre Leistung und erhalten keine neue Vorgabe. Die übrigen Verbraucher werden
nach der gemeldeten PV- beziehungsweise Peak-Priorität und danach stabil nach
Instanz-ID sortiert. Von ihrem jeweils kleinsten erlaubten Leistungswert aus
wird das Budget in dieser Reihenfolge aufgefüllt. Einzelwerte und ganzzahlige
Bereiche werden direkt verarbeitet; Lücken werden nie durch unzulässige Werte
geschlossen. Eine verbleibende Abweichung wird im `Verbraucherstatus`
dokumentiert.
Die Betriebsart wechselt unterhalb des Solar-Sollwerts zu `PV` und oberhalb
der wirksamen Lastspitzengrenze zu `Peak`. Zwischen den beiden Zielwerten
bleibt sie erhalten. `Umschaltdifferenz` verhindert zusätzlich einen Wechsel,
wenn sich die beiden berechneten Korrekturen nicht ausreichend unterscheiden.
## Laufzeit und Fehlerverhalten
- Netzleistungsänderungen und Verbrauchermeldungen lösen die Berechnung aus.
- Identische Sollwerte werden nur beim Keep-alive erneut gesendet.
- Diagnosevariablen und laufendes Debug-Logging werden getrennt aktiviert.
- Verbraucherpakete werden zentral geprüft und nur von aktiv zugeordneten
Absendern angenommen.
- Veraltete oder noch fehlende Verbrauchermeldungen werden nicht verteilt und
als Sammelstörung ausgewiesen.
- Bei fehlender oder veralteter Netzleistung bleibt die Regelung `Inaktiv` und
sendet keine neuen Vorgaben.
- Die oberen Anschlüsse bleiben optional. Solange kein konkreter Adapter
implementiert ist, meldet ein aktivierter Anschluss den Status `Fehler`, ohne
die lokale EMS-Regelung zu blockieren.
## Schnittstellen
- Empfängt genau `VerbraucherdatenEmpfangen(array $daten)`.
- Sendet an jeden Verbraucher nur Kopf und `Sollleistung_W`.
- Obere Anschlüsse: siehe [Obere Anschlüsse](../../Obere-Anschluesse.md).
## Offene Punkte
- Monatliche Batteriereserve aus dem alten Peakshaving-Modul übernehmen?
- Anbieterformate für Prognose, Lizenzierung und Störüberwachung festlegen.
- Verhalten und Messabgrenzung bei Untermanagern im Anlagentest bestätigen.
+54
View File
@@ -0,0 +1,54 @@
# Pufferspeicher
> Status: Diskussionsentwurf.
Regelt elektrische Heizstufen nach Pufferbedarf. Die Heizkurve verwendet einen
Fusspunkt bei 20 °C Aussentemperatur und eine Steigung:
`Solltemperatur = FusspunktVorlauftemperatur + HeizkurvenSteigung × (20 - Aussentemperatur)`
Anschliessend wird auf Mindest- und Maximaltemperatur begrenzt.
## Zusätzliche Variablen
| Ident | Typ / Zugriff | Beschreibung |
| --- | --- | --- |
| `Puffertemperatur` | Float / Anzeige | Aktuelle Temperatur in °C. |
| `Aussentemperatur` | Float / Anzeige | Heizkurveneingang in °C. |
| `TemperaturenGueltig` | Boolean / Anzeige | Beide Quellen sind gültig. |
| `Solltemperatur` | Float / Anzeige | Berechneter und begrenzter Sollwert in °C. |
| `Heizbedarf` | Boolean / Anzeige | Bedarf unter Berücksichtigung der Hysterese. |
| `AktiveStufe` | Integer / Anzeige | Stufenkennung; `0` bedeutet aus. |
| `FusspunktVorlauftemperatur` | Float / lokal bedienbar | Wirksamer Parameter in °C. |
| `HeizkurvenSteigung` | Float / lokal bedienbar | Anhebung pro K fallender Aussentemperatur. |
| `Mindesttemperatur` | Float / lokal bedienbar | Untere Begrenzung in °C. |
| `Maximaltemperatur` | Float / lokal bedienbar | Obere Begrenzung in °C. |
| `Hysterese` | Float / lokal bedienbar | Ein unter Soll minus Hysterese, aus bei Soll. |
## Zusätzliche Properties
| Ident | Typ | Standard / Beschreibung |
| --- | --- | --- |
| `FusspunktVorlauftemperatur` | Float | `0*` °C; Vorlauf bei 20 °C aussen. |
| `HeizkurvenSteigung` | Float | `0*`; mindestens 0. |
| `Mindesttemperatur` | Float | `0*` °C. |
| `Maximaltemperatur` | Float | `0*` °C. |
| `Hysterese` | Float | `1` K; grösser 0. |
| `Leistungsstufen` | String/JSON | `[]`; Stufenkennung, Leistung in W und Ausgangsbelegung. |
| `PuffertemperaturVariableID` | Integer | `0`; Temperaturquelle. |
| `AussentemperaturVariableID` | Integer | `0`; Aussentemperaturquelle. |
| `Mindestlaufzeit` | Integer | `0` s. |
| `Mindeststillstand` | Integer | `0` s. |
Vier bisherige Heizkurventemperaturen, beide Heizkurvenquellen, Übernahmeaktion,
Zeitplan und Zeitzone entfallen.
## Zustand
`Puffertemperatur_C` und `Aussentemperatur_C` als Istwerte,
`Solltemperatur_C` als Sollwert, `Heizbedarf` als Status und `Fuehlerfehler` als
Störung.
## Offene Punkte
- Optionale Einbindung der Wärmepumpe beim Pufferheizen ausarbeiten.
+26
View File
@@ -0,0 +1,26 @@
# EMS-Module und Modulentwürfe
> Manager, Warmwassererwaermer und Verbraucher 1-Stufig sind als installierbare
> IP-Symcon-Module umgesetzt. Die weiteren Ordner enthalten Besprechungsgrundlagen.
Alle steuerbaren Verbraucher verwenden die gemeinsamen Datenpunkte aus der
[EMS-Schnittstelle](../Schnittstelle.md). In den Modul-READMEs stehen deshalb
nur zusätzliche Properties, Variablen, Zustände und offene Punkte.
| Modul | Rolle |
| --- | --- |
| [Manager](Manager/README.md) | Leistungsverteilung und Anschlüsse (implementiert) |
| [Batterie](Batterie/README.md) | Laden und Entladen eines Speichers |
| [Wassererwärmer](Wassererwaermer/README.md) | Mehrstufige elektrische Erwärmung (implementiert) |
| [Pufferspeicher](Pufferspeicher/README.md) | Heizstufen nach Heizkurve |
| [Verbraucher 1-Stufig](Verbraucher-1-Stufig/README.md) | Ein-/Aus-Verbraucher (implementiert) |
| [Wärmepumpe](Waermepumpe/README.md) | Sperrkontakt oder SG Ready |
| [Ladestation Stand-Alone](Ladestation-Stand-Alone/README.md) | Direkte Geräteanbindung |
| [Ladestation Gateway](Ladestation-Gateway/README.md) | Ladestation am Easee Gateway |
| [Easee Gateway](Easee-Gateway/README.md) | Gemeinsame Easee-Kommunikation |
## Review-Regel
`0*` bezeichnet einen noch nicht eingerichteten Anlagenwert. Offene Punkte
werden nicht durch Annahmen ersetzt. Erst nach Freigabe wird aus einem Entwurf
ein Ordner mit `module.json`, `form.json`, `module.php` und Unit-Tests.
+156
View File
@@ -0,0 +1,156 @@
# Verbraucher 1-Stufig
> Status: implementiert fuer IP-Symcon 8 und den Enelix-2-Vertrag `3.0`.
Das Modul schaltet einen elektrischen Ein-/Aus-Verbraucher. Im freien Zustand
meldet es dem Manager das Leistungsangebot `[0, Nennleistung]`. Waehrend der
Umschaltsperre und bei lokal erzwungener Mindestlaufzeit wird nur der aktuell
zulaessige Leistungswert angeboten.
## Architekturentscheidung
Die Regelung ist ereignisbasiert. Neuberechnungen werden ausgeloest durch:
- eine neue Manager-Vorgabe,
- eine Aenderung an Schaltkontakt oder optionaler Rueckmeldung,
- das Ein- oder Ausschalten der lokalen EMS-Freigabe,
- das Ende des Umschaltabstands,
- das Ablaufen einer Manager-Vorgabe,
- den naechsten relevanten Zeitpunkt der Tageslaufzeitplanung.
Es gibt keinen Regelzyklus, kein `Interval` und keinen `IdleCounterMax`.
`Meldeintervall` bleibt als vertraglich geforderte periodische Vollmeldung
bestehen und steuert keine Regelberechnung.
## Properties
Zu den sechs gemeinsamen Verbraucher-Properties aus
[`Schnittstelle.md`](../../Schnittstelle.md) kommen sieben
Modul-Properties hinzu.
| Ident | Typ | Standard | Beschreibung |
| --- | --- | ---: | --- |
| `Nennleistung` | Integer | `0` | Positive elektrische Leistung in W. |
| `SchaltkontaktVariableID` | Integer | `0` | Boolean-Aktor mit Standard- oder benutzerdefinierter Aktion. |
| `SchaltkontaktInvertiert` | Boolean | `false` | Kehrt die Ein-/Aus-Semantik des Aktors um. |
| `RueckmeldungVariableID` | Integer | `0` | Optionale Boolean-Rueckmeldung; `true` bedeutet eingeschaltet. |
| `Mindestlaufzeit` | Integer | `0` | Geforderte Laufzeit pro lokalem Kalendertag in Sekunden, maximal 86400. |
| `Umschaltabstand` | Integer | `5` | Sekunden bis zum naechsten erlaubten Lastwechsel. |
| `DiagnosevariablenAnzeigen` | Boolean | `false` | Legt gemeinsame Diagnosevariablen und `Rueckmeldefehler` an. |
`EinstellungenInVisu` ist Teil der gemeinsamen Verbraucherbasis. Das Modul
besitzt derzeit keine zusaetzlichen bedienbaren Einstellvariablen und wertet
diese Property deshalb noch nicht weiter aus.
## Variablen
Immer sichtbar:
| Ident | Typ / Zugriff | Beschreibung |
| --- | --- | --- |
| `Aktiv` | Boolean / bedienbar | Lokale EMS-Freigabe; Startwert `false`. |
| `Schaltzustand` | Boolean / Anzeige | Rueckgemeldeter oder aus dem Aktor berechneter Zustand. |
| `Tageslaufzeit` | Integer / Anzeige | Laufzeit des lokalen Kalendertags in Sekunden. |
Mit `DiagnosevariablenAnzeigen` werden diese Variablen zusaetzlich angelegt:
- `Istleistung`
- `Leistungsquelle`
- `Sollleistung`
- `SollwertGueltig`
- `Verfuegbar`
- `AenderungMoeglich`
- `Stoerung`
- `Stoertext`
- `Rueckmeldefehler`
Die Regellogik arbeitet mit persistenten Attributen und ist nicht von der
Sichtbarkeit der Diagnosevariablen abhaengig.
## Zeit- und Schaltverhalten
Nach jedem Lastwechsel wird `AenderungMoeglich=false` gemeldet und das
Leistungsangebot auf den aktuellen Wert eingeschraenkt. Nach
`Umschaltabstand` Sekunden gibt ein einmaliger Timer den naechsten
Lastwechsel frei. Der Standardwert betraegt 5 Sekunden.
Die Tageslaufzeit wird mit der in IP-Symcon eingestellten lokalen Zeitzone
gefuehrt. Sommer- und Winterzeit sowie der Wechsel um Mitternacht werden
beruecksichtigt. Ist die konfigurierte Mindestlaufzeit noch nicht erreicht,
wird der Verbraucher erst zum spaetestmoeglichen Zeitpunkt lokal
eingeschaltet, an dem die Restlaufzeit bis Mitternacht noch erfuellt werden
kann. In dieser Phase meldet er `AenderungMoeglich=false`.
Bei Aktivierung sehr kurz vor Tagesende kann eine unmoeglich hohe Restlaufzeit
naturgemaess nicht mehr vollstaendig nachgeholt werden. Der Verbraucher wird
dann sofort eingeschaltet und die tatsaechliche Laufzeit erfasst.
## Rueckmeldung und Fehler
Ohne separate Rueckmeldung wird der Schaltzustand aus der Aktorvariable
berechnet. Mit `RueckmeldungVariableID` wartet das Modul bis zum Ende des
Umschaltabstands auf den angeforderten Zustand. Liegt er dann nicht an, werden
`Rueckmeldefehler=true`, `Stoerung=true` und `Verfuegbar=false` gemeldet.
Die Istleistung ist immer berechnet: `0` oder `Nennleistung`. Deshalb wird
`Leistungsquelle=1` gemeldet, auch wenn der Boolean-Schaltzustand ueber eine
physische Rueckmeldung erfasst wird.
## Managerkommunikation
Das Modul implementiert
`VerbraucherSchnittstelle::ManagerdatenEmpfangen()` und verwendet
ausschliesslich den Vertrag `3.0`. Der Verbraucher besitzt keine
Manager-ID-Property. Er akzeptiert nur Manager, in deren manueller oder
automatischer Verbraucherzuordnung seine Instanz aktiv eingetragen ist.
Rueckmeldungen erfolgen bei relevanten Ereignissen, kurz verzoegert nach einer
Manager-Vorgabe und zusaetzlich alle `Meldeintervall` Sekunden. Eine Vorgabe
wird nach `VorgabeTimeout` Sekunden ohne Erneuerung ungueltig.
## Installation und Inbetriebnahme
1. Im IP-Symcon Module Control den Testing-Branch `develop` der Bibliothek
`https://git.belevo.ch/ENELIX/Enelix-EMS.git` installieren oder
aktualisieren.
2. Unter **Instanz hinzufuegen** nach **Verbraucher 1-Stufig** suchen und eine
Instanz anlegen.
3. `Nennleistung` in ganzen Watt eintragen.
4. Als `Schaltkontakt` eine Booleanvariable mit funktionsfaehiger Aktion
auswaehlen. Bei umgekehrter Aktorlogik `SchaltkontaktInvertiert`
aktivieren.
5. Optional eine separate Boolean-Rueckmeldung auswaehlen. Dort muss
`true` dem physisch eingeschalteten Verbraucher entsprechen.
6. Prioritaeten, `Mindestlaufzeit`, `Umschaltabstand`,
`Meldeintervall` und `VorgabeTimeout` einstellen und die Konfiguration
uebernehmen.
7. Die neue Instanz im Manager manuell aktiv zuordnen oder bei automatischer
Suche in der gefundenen Liste aktivieren.
8. Fuer die Erstpruefung `DiagnosevariablenAnzeigen` und bei Bedarf
`LoggingEin` einschalten.
9. Unter Aufsicht `Aktiv` einschalten und ueber den Manager je eine Ein- und
Aus-Vorgabe pruefen.
## Abnahmecheckliste
- `Aktiv=false` schaltet den Ausgang aus.
- `Aktiv=true` und eine Manager-Vorgabe `Nennleistung` schalten den Ausgang
ein.
- Direkt nach einem Wechsel ist `AenderungMoeglich=false`; nach 5 Sekunden
wird es mit Standardkonfiguration wieder `true`.
- Eine Gegenanforderung waehrend der Sperre wird vom Leistungsangebot
ausgeschlossen.
- Die optionale Rueckmeldung folgt dem Ausgang innerhalb des
Umschaltabstands; andernfalls erscheint eine Stoerung.
- `Tageslaufzeit` steigt nur im eingeschalteten Zustand und beginnt am
lokalen Tageswechsel wieder bei 0.
- Nach Ablauf von `VorgabeTimeout` wird ohne neue Manager-Vorgabe
ausgeschaltet, sobald der Umschaltabstand dies zulaesst.
- Nach Neustart oder erneuter Konfiguration wird der reale Schaltzustand
eingelesen; alte Manager-Vorgaben werden nicht ungeprueft fortgesetzt.
## Migration
Die Zuordnung der Enelix-1-Felder, Einheiten und bewusst entfallenen
Kommunikationsvariablen steht in
[`docs/migration/Verbraucher-1-Stufig.md`](../../migration/Verbraucher-1-Stufig.md).
+47
View File
@@ -0,0 +1,47 @@
# Wärmepumpe
> Status: Diskussionsentwurf.
Die Kontaktwirkung ist als Sperrkontakte oder SG Ready konfigurierbar. Die
elektrische Leistung kann fehlen, berechnet oder gemessen werden.
## Zusätzliche Variablen
| Ident | Typ / Zugriff | Beschreibung |
| --- | --- | --- |
| `Waermepumpenzustand` | Integer / Anzeige | `0` unbekannt, `1` aus, `2` normal, `3` erhöht, `4` gesperrt. |
| `SperreAktiv` | Boolean / Anzeige | Aktuelle Sperranforderung. |
| `ErhoehungAktiv` | Boolean / Anzeige | Aktuelle Erhöhungsanforderung. |
| `SGReadyZustand` | Integer / Anzeige | Bei SG Ready Zustand 0–3. |
| `Laufzeit` | Integer / Anzeige | Kumulierte bestätigte Laufzeit in Sekunden. |
## Zusätzliche Properties
| Ident | Typ | Standard / Beschreibung |
| --- | --- | --- |
| `Kontaktwirkung` | Auswahl | `Sperrkontakte`; alternativ `SG Ready`. |
| `Kontakt1VariableID` | Integer | `0`; Sperre oder SG-Ready-Eingang 1. |
| `Kontakt2VariableID` | Integer | `0`; Erhöhung oder SG-Ready-Eingang 2. |
| `Kontakt1Invertiert` | Boolean | `false`. |
| `Kontakt2Invertiert` | Boolean | `false`. |
| `Leistungsermittlung` | Auswahl | `Nicht vorhanden`, `Berechnet` oder `Gemessen`; entspricht Quelle 0/1/2. |
| `LeistungsmessungVariableID` | Integer | `0`; nur bei gemessener Leistung. |
| `Zustandskennlinie` | String/JSON | `[]`; bei berechneter Leistung je Zustand Planleistung und Kontaktbelegung. |
| `BetriebsrueckmeldungVariableID` | Integer | `0`; optionale echte Betriebsrückmeldung. |
| `Mindestlaufzeit` | Integer | `0*` s; anlagengerecht festlegen. |
| `Mindeststillstand` | Integer | `0*` s; anlagengerecht festlegen. |
| `MaxSperrzeit` | Integer | `0` s; `0` deaktiviert die EMS-Sperrfunktion. |
Zeit- und wetterabhängiges Sperren beziehungsweise Erhöhen aus dem Altmodul
entfällt.
## Zustand
`Waermepumpenzustand`, `SperreAktiv`, `ErhoehungAktiv` beziehungsweise
`SGReadyZustand` als Status, `Laufzeit_s` als Istwert und
`Kommunikationsfehler` als Störung.
## Offene Punkte
- Der Formularbutton „Wassererwärmer über Wärmepumpe betreiben“ bleibt ohne
Regelwirkung, bis Anschluss und Verriegelung gemeinsam definiert sind.
+109
View File
@@ -0,0 +1,109 @@
# Verbraucher Warmwassererwaermer
> Status: implementiert fuer IP-Symcon 8 und den Enelix-2-Vertrag `3.0`.
Das Modul adaptiert ausschliesslich das fruehere Modul `Boiler_x_Stufig`.
Mehrere elektrische Leistungsstufen werden exklusiv geschaltet. Der Manager
waehlt eine der aktuell angebotenen Leistungen; Mindesttemperatur, Zeitplan und
Legionellenprogramm koennen die lokale Warmwassersicherheit priorisieren.
## Sicherheit und Regelung
- Die Schaltkontakte werden mit Break-before-make geschaltet: zuerst alle aus,
danach hoechstens eine Stufe ein.
- Fehlende, veraltete oder nicht numerische Temperaturwerte schalten die
EMS-Verfuegbarkeit aus und fuehren zu einer Stoerung.
- Manager-Vorgaben werden nur angenommen, wenn sie im zuletzt gemeldeten
`Leistungswerte_W`-Angebot enthalten sind.
- Nach `VorgabeTimeout` wird eine nicht erneuerte Manager-Vorgabe ungueltig.
- Mindesttemperatur, vorausberechneter Zeitplan und Legionellenprogramm koennen
die maximale Stufe lokal erzwingen. Diese Phase wird mit
`AenderungMoeglich=false` gemeldet.
- Lastwechsel werden fuer `LastwechselSperrzeit` Sekunden gesperrt. Danach
erfolgt genau eine ereignisgesteuerte Neuberechnung; sicherheitsbedingtes
Ausschalten bleibt sofort moeglich.
- `BoilertemperaturGlaetten` aktiviert eine PT1-Glaettung mit konfigurierbarer
Zeitkonstante und der tatsaechlich verstrichenen Zeit.
## Properties
Zu den sechs gemeinsamen Verbraucher-Properties aus
[`Schnittstelle.md`](../../Schnittstelle.md) kommen 13 Modul-Properties hinzu.
| Ident | Typ | Standard | Beschreibung |
| --- | --- | ---: | --- |
| `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. |
| `LastwechselSperrzeit` | Integer | `5` | Mindestzeit zwischen zwei Lastwechseln in Sekunden. |
| `TemperaturMaxAlter` | Integer | `30` | Maximal zulaessiges Messwertalter in Sekunden. |
| `Hysterese` | Float | `5.0` | Temperaturhysterese in K. |
| `LegionellenfunktionAktiv` | Boolean | `true` | Zweistufige periodische Aufheizung aktiv. |
| `LegionellenMinimalintervallTage` | Integer | `4` | Ab diesem Intervall wird die Legionellentemperatur als Maximaltemperatur angeboten. |
| `LegionellenMaximalintervallTage` | Integer | `7` | Ab diesem Intervall wird die Legionellentemperatur zwingend nachgeladen. |
| `DiagnosevariablenAnzeigen` | Boolean | `false` | Legt 16 Diagnosevariablen bei Bedarf an. |
`LoggingEin` aktiviert laufende Debug-Ausgaben.
## Variablen
Standardmaessig sichtbar sind nur `Aktiv` und `Boilertemperatur`.
Mit `EinstellungenInVisu` werden `Mindesttemperatur` und `Maximaltemperatur`
sichtbar und geprueft bedienbar. `Legionellentemperatur` wird nur angelegt,
wenn `LegionellenfunktionAktiv` eingeschaltet ist, und ist ebenfalls nur mit
`EinstellungenInVisu` sichtbar. Beim Abschalten der Legionellenfunktion wird
die Variable geloescht; ihr letzter Wert bleibt intern fuer ein spaeteres
Wiedereinschalten erhalten. Die Reihenfolge
`Mindesttemperatur < Maximaltemperatur <= Legionellentemperatur` ist zwingend.
Mit `DiagnosevariablenAnzeigen` werden diese 14 Variablen angelegt:
- `Istleistung`
- `Leistungsquelle`
- `Sollleistung`
- `SollwertGueltig`
- `Verfuegbar`
- `AenderungMoeglich`
- `Stoerung`
- `Stoertext`
- `AktiveStufe`
- `BezogeneEnergie`
- `TemperaturGueltig`
- `NachladenAktiv`
- `LegionellenbetriebAktiv`
- `LegioCounter`
Die Regelung und Managerkommunikation verwenden fuer diese Werte eine interne
Zustandsablage und funktionieren auch ohne angelegte Diagnosevariablen weiter.
Die Lastwechselsperre wird ausschliesslich ueber die Schnittstellenfelder
`AenderungMoeglich` und `Leistungswerte_W` abgebildet.
## 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 manueller oder automatischer 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, thermische Prognose und die zweistufigen Legionellengrenzen.
`WarmwassererwaermerModulstrukturTest.php` prueft Metadaten, Formular,
19 Properties, bedarfsgesteuerte Diagnosevariablen, 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.
+1 -1
View File
@@ -1,7 +1,7 @@
{
"id": "{E0185158-0DA9-4D44-BAA7-B06D3D786276}",
"name": "Enelix EMS",
"author": "Belevo AG",
"author": "Enelix",
"url": "https://git.belevo.ch/ENELIX/Enelix-EMS",
"compatibility": {
"version": "8.0"
+144
View File
@@ -0,0 +1,144 @@
<?php
declare(strict_types=1);
namespace Belevo\EnelixEMS;
use DateTimeImmutable;
use DateTimeZone;
use InvalidArgumentException;
final class EinStufigRegler
{
/**
* @return list<int>
*/
public static function leistungsangebot(
int $nennleistung,
int $istleistung,
bool $verfuegbar,
bool $aenderungMoeglich,
bool $mindestlaufzeitErzwungen
): array {
if ($nennleistung <= 0) {
throw new InvalidArgumentException('Die Nennleistung muss groesser als 0 sein.');
}
if (!in_array($istleistung, [0, $nennleistung], true)) {
throw new InvalidArgumentException('Die Istleistung muss 0 oder der Nennleistung entsprechen.');
}
if (!$verfuegbar || !$aenderungMoeglich || $mindestlaufzeitErzwungen) {
return [$istleistung];
}
return [0, $nennleistung];
}
public static function mussMindestlaufzeitErzwingen(
int $tageslaufzeit,
int $mindestlaufzeit,
int $sekundenBisTagesende
): bool {
self::pruefeLaufzeiten($tageslaufzeit, $mindestlaufzeit, $sekundenBisTagesende);
$restlaufzeit = max(0, $mindestlaufzeit - $tageslaufzeit);
return $restlaufzeit > 0 && $sekundenBisTagesende <= $restlaufzeit;
}
public static function sekundenBisNaechsteTagespruefung(
int $tageslaufzeit,
int $mindestlaufzeit,
int $sekundenBisTagesende,
bool $eingeschaltet
): int {
self::pruefeLaufzeiten($tageslaufzeit, $mindestlaufzeit, $sekundenBisTagesende);
$restlaufzeit = max(0, $mindestlaufzeit - $tageslaufzeit);
if ($restlaufzeit === 0) {
return max(1, $sekundenBisTagesende);
}
if ($eingeschaltet) {
return max(1, min($restlaufzeit, $sekundenBisTagesende));
}
return max(1, $sekundenBisTagesende - $restlaufzeit);
}
/**
* @return array{Tag: string, Sekunden: int, StandZeit: int}
*/
public static function aktualisiereTageslaufzeit(
string $gespeicherterTag,
int $gespeicherteSekunden,
int $standZeit,
int $jetzt,
bool $warEingeschaltet,
DateTimeZone $zeitzone
): array {
if ($gespeicherteSekunden < 0 || $standZeit < 0 || $jetzt < 0) {
throw new InvalidArgumentException('Zeitwerte duerfen nicht negativ sein.');
}
$jetztDatum = self::datum($jetzt, $zeitzone);
$aktuellerTag = $jetztDatum->format('Y-m-d');
if ($standZeit <= 0 || $standZeit > $jetzt) {
return [
'Tag' => $aktuellerTag,
'Sekunden' => $gespeicherterTag === $aktuellerTag ? $gespeicherteSekunden : 0,
'StandZeit' => $jetzt,
];
}
$standDatum = self::datum($standZeit, $zeitzone);
$sekunden = $gespeicherteSekunden;
if ($gespeicherterTag !== $aktuellerTag || $standDatum->format('Y-m-d') !== $aktuellerTag) {
$sekunden = 0;
if ($warEingeschaltet) {
$tagesbeginn = $jetztDatum->setTime(0, 0)->getTimestamp();
$sekunden = max(0, $jetzt - max($standZeit, $tagesbeginn));
}
} elseif ($warEingeschaltet) {
$sekunden += $jetzt - $standZeit;
}
$tagesbeginn = $jetztDatum->setTime(0, 0);
$tagesdauer = $tagesbeginn->modify('+1 day')->getTimestamp() - $tagesbeginn->getTimestamp();
return [
'Tag' => $aktuellerTag,
'Sekunden' => min($sekunden, $tagesdauer),
'StandZeit' => $jetzt,
];
}
public static function sekundenBisTagesende(int $jetzt, DateTimeZone $zeitzone): int
{
if ($jetzt < 0) {
throw new InvalidArgumentException('Der Zeitpunkt darf nicht negativ sein.');
}
$datum = self::datum($jetzt, $zeitzone);
$tagesende = $datum->setTime(0, 0)->modify('+1 day');
return max(1, $tagesende->getTimestamp() - $jetzt);
}
private static function pruefeLaufzeiten(
int $tageslaufzeit,
int $mindestlaufzeit,
int $sekundenBisTagesende
): void {
if ($tageslaufzeit < 0 || $mindestlaufzeit < 0 || $mindestlaufzeit > 86400) {
throw new InvalidArgumentException('Laufzeiten sind ausserhalb des gueltigen Bereichs.');
}
if ($sekundenBisTagesende <= 0) {
throw new InvalidArgumentException('Sekunden bis Tagesende muss groesser als 0 sein.');
}
}
private static function datum(int $zeitpunkt, DateTimeZone $zeitzone): DateTimeImmutable
{
return (new DateTimeImmutable('@' . $zeitpunkt))->setTimezone($zeitzone);
}
}
+250
View File
@@ -0,0 +1,250 @@
<?php
declare(strict_types=1);
namespace Belevo\EnelixEMS;
use InvalidArgumentException;
/**
* Reine Regel- und Verteilfunktion des Managers.
*
* Die Klasse kennt keine IP-Symcon-Funktionen und kann deshalb unabhaengig
* vom Laufzeitsystem getestet werden. Alle Leistungen verwenden die
* Vorzeichenkonvention des Vertrags: positiv Bezug, negativ Abgabe.
*/
final class ManagerRegler
{
public const BETRIEBSART_INAKTIV = 'Inaktiv';
public const BETRIEBSART_PV = 'PV';
public const BETRIEBSART_PEAK = 'Peak';
/**
* @param array<int, float|int> $monatsgrenzen Monatsnummer 1 bis 12 als Schluessel
*/
public static function wirksameLastspitzengrenze(
int $modus,
float $konstanteGrenze,
array $monatsgrenzen,
int $monat
): ?float {
if ($modus === 0) {
return null;
}
if ($modus === 1) {
return $konstanteGrenze;
}
if ($modus !== 2) {
throw new InvalidArgumentException('Lastspitzenmodus muss 0, 1 oder 2 sein.');
}
if ($monat < 1 || $monat > 12 || !array_key_exists($monat, $monatsgrenzen)) {
throw new InvalidArgumentException('Fuer den aktuellen Monat fehlt eine Lastspitzengrenze.');
}
return (float) $monatsgrenzen[$monat];
}
public static function bestimmeBetriebsart(
bool $aktiv,
bool $netzleistungGueltig,
float $netzleistung,
string $bisherigeBetriebsart,
bool $peakAktiv,
float $sollwertSolarladen,
float $wirksameLastspitzengrenze,
float $umschaltdifferenzProzent
): string {
if (!$aktiv || !$netzleistungGueltig) {
return self::BETRIEBSART_INAKTIV;
}
if (!$peakAktiv) {
return self::BETRIEBSART_PV;
}
if ($umschaltdifferenzProzent < 0 || $umschaltdifferenzProzent > 100) {
throw new InvalidArgumentException('Umschaltdifferenz muss zwischen 0 und 100 Prozent liegen.');
}
$kandidat = $bisherigeBetriebsart;
if ($netzleistung < $sollwertSolarladen) {
$kandidat = self::BETRIEBSART_PV;
} elseif ($netzleistung > $wirksameLastspitzengrenze) {
$kandidat = self::BETRIEBSART_PEAK;
} elseif (!in_array($kandidat, [self::BETRIEBSART_PV, self::BETRIEBSART_PEAK], true)) {
$kandidat = self::BETRIEBSART_PV;
}
if ($kandidat === $bisherigeBetriebsart
|| !in_array($bisherigeBetriebsart, [self::BETRIEBSART_PV, self::BETRIEBSART_PEAK], true)
|| $umschaltdifferenzProzent === 0.0
) {
return $kandidat;
}
$pvKorrektur = $sollwertSolarladen - $netzleistung;
$peakKorrektur = $wirksameLastspitzengrenze - $netzleistung;
$bezugswert = max(abs($pvKorrektur), abs($peakKorrektur), 1.0);
$differenzProzent = abs($pvKorrektur - $peakKorrektur) / $bezugswert * 100.0;
return $differenzProzent >= $umschaltdifferenzProzent
? $kandidat
: $bisherigeBetriebsart;
}
/**
* @param list<array<string, mixed>> $verbraucher Vollstaendige, bereits gepruefte Verbraucherdaten
* @return array{
* Verteilbudget_W: int,
* AktuelleVerbraucherleistung_W: int,
* NichtSteuerbar_W: int,
* Sollwerte: array<int, int>,
* Restabweichung_W: int
* }
*/
public static function verteile(
float $netzleistung,
float $zielnetzleistung,
array $verbraucher,
string $prioritaetsfeld
): array {
if (!in_array($prioritaetsfeld, ['PrioritaetPV', 'PrioritaetPeak'], true)) {
throw new InvalidArgumentException('Unbekanntes Prioritaetsfeld.');
}
$aktuelleLeistung = 0;
$nichtSteuerbar = 0;
$steuerbar = [];
foreach ($verbraucher as $eintrag) {
self::pruefeVerteilEintrag($eintrag, $prioritaetsfeld);
$leistung = self::aktuelleLeistung($eintrag['Daten']);
$aktuelleLeistung += $leistung;
$daten = $eintrag['Daten'];
if (!$daten['Verfuegbar'] || !$daten['AenderungMoeglich'] || $daten['Leistungswerte_W'] === []) {
$nichtSteuerbar += $leistung;
continue;
}
$steuerbar[] = [
'InstanzID' => $eintrag['InstanzID'],
'Prioritaet' => $daten[$prioritaetsfeld],
'Leistungswerte_W' => $daten['Leistungswerte_W'],
];
}
usort($steuerbar, static function (array $links, array $rechts): int {
return [$links['Prioritaet'], $links['InstanzID']] <=> [$rechts['Prioritaet'], $rechts['InstanzID']];
});
$verteilbudget = (int) round($aktuelleLeistung + $zielnetzleistung - $netzleistung);
$sollwerte = [];
$minimumSumme = 0;
foreach ($steuerbar as $eintrag) {
$minimum = self::kleinsterLeistungswert($eintrag['Leistungswerte_W']);
$sollwerte[$eintrag['InstanzID']] = $minimum;
$minimumSumme += $minimum;
}
$verfuegbar = max(0, $verteilbudget - $nichtSteuerbar - $minimumSumme);
foreach ($steuerbar as $eintrag) {
$instanzID = $eintrag['InstanzID'];
$minimum = $sollwerte[$instanzID];
$neuerSollwert = self::groessterLeistungswertBis(
$eintrag['Leistungswerte_W'],
$minimum + $verfuegbar
);
$verfuegbar -= $neuerSollwert - $minimum;
$sollwerte[$instanzID] = $neuerSollwert;
}
$zugeteilt = $nichtSteuerbar + array_sum($sollwerte);
return [
'Verteilbudget_W' => $verteilbudget,
'AktuelleVerbraucherleistung_W' => $aktuelleLeistung,
'NichtSteuerbar_W' => $nichtSteuerbar,
'Sollwerte' => $sollwerte,
'Restabweichung_W' => $verteilbudget - $zugeteilt,
];
}
/**
* @param array<string, mixed> $daten
*/
public static function angenommenerSollwert(array $daten): ?int
{
foreach ($daten['Zustand'] as $zustand) {
if ($zustand['Kennung'] === 'Sollleistung_W') {
return is_int($zustand['Wert']) ? $zustand['Wert'] : null;
}
}
return null;
}
/**
* @param array<string, mixed> $daten
*/
private static function aktuelleLeistung(array $daten): int
{
if ($daten['Leistungsquelle'] !== Nachrichtenvertrag::LEISTUNGSQUELLE_NICHT_VORHANDEN
&& is_numeric($daten['Istleistung_W'])
) {
return (int) round((float) $daten['Istleistung_W']);
}
return self::angenommenerSollwert($daten) ?? 0;
}
/**
* @param array<string, mixed> $eintrag
*/
private static function pruefeVerteilEintrag(array $eintrag, string $prioritaetsfeld): void
{
if (!isset($eintrag['InstanzID'], $eintrag['Daten'])
|| !is_int($eintrag['InstanzID'])
|| $eintrag['InstanzID'] <= 0
|| !is_array($eintrag['Daten'])
) {
throw new InvalidArgumentException('Verbrauchereintrag ist ungueltig.');
}
Nachrichtenvertrag::pruefeVerbraucherdaten($eintrag['Daten']);
if (!array_key_exists($prioritaetsfeld, $eintrag['Daten'])) {
throw new InvalidArgumentException('Prioritaet fehlt im Verbrauchereintrag.');
}
}
/**
* @param list<int|array{Von_W: int, Bis_W: int}> $leistungswerte
*/
private static function kleinsterLeistungswert(array $leistungswerte): int
{
$erster = $leistungswerte[0];
return is_int($erster) ? $erster : $erster['Von_W'];
}
/**
* @param list<int|array{Von_W: int, Bis_W: int}> $leistungswerte
*/
private static function groessterLeistungswertBis(array $leistungswerte, int $obergrenze): int
{
$gewaehlt = self::kleinsterLeistungswert($leistungswerte);
foreach ($leistungswerte as $eintrag) {
if (is_int($eintrag)) {
if ($eintrag > $obergrenze) {
break;
}
$gewaehlt = $eintrag;
continue;
}
if ($eintrag['Von_W'] > $obergrenze) {
break;
}
$gewaehlt = min($eintrag['Bis_W'], $obergrenze);
}
return $gewaehlt;
}
}
+2 -1
View File
@@ -9,8 +9,9 @@ interface ManagerSchnittstelle
/**
* Empfaengt den vollstaendigen aktuellen Datensatz eines Verbrauchers.
*
* Dies ist die einzige fachliche Empfangsmethode des Managers.
*
* @param array<string, mixed> $daten
*/
public function VerbraucherdatenEmpfangen(array $daten): void;
}
+39
View File
@@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace Belevo\EnelixEMS;
/**
* Registriert die gemeinsamen Properties und Variablen aller EMS-Verbraucher.
*
* Das Trait ist noch keine vollstaendige Steuerungsimplementierung. Die
* konkreten Module ergaenzen Geraeteanbindung, Leistungsangebot und Zustand.
*/
trait VerbraucherBasisTrait
{
protected function registriereVerbraucherBasis(): void
{
$this->RegisterPropertyInteger('PrioritaetPV', 0);
$this->RegisterPropertyInteger('PrioritaetPeak', 0);
$this->RegisterPropertyInteger('Meldeintervall', 10);
$this->RegisterPropertyInteger('VorgabeTimeout', 120);
$this->RegisterPropertyBoolean('EinstellungenInVisu', false);
$this->RegisterPropertyBoolean('LoggingEin', false);
$this->RegisterVariableBoolean('Aktiv', 'Aktiv', '~Switch', 10);
$this->EnableAction('Aktiv');
}
protected function registriereVerbraucherDiagnose(): void
{
$this->RegisterVariableFloat('Istleistung', 'Istleistung', '', 20);
$this->RegisterVariableInteger('Leistungsquelle', 'Leistungsquelle', '', 30);
$this->RegisterVariableInteger('Sollleistung', 'Sollleistung', '', 40);
$this->RegisterVariableBoolean('SollwertGueltig', 'Sollwert gueltig', '~Switch', 50);
$this->RegisterVariableBoolean('Verfuegbar', 'Verfuegbar', '~Switch', 60);
$this->RegisterVariableBoolean('AenderungMoeglich', 'Aenderung moeglich', '~Switch', 70);
$this->RegisterVariableBoolean('Stoerung', 'Stoerung', '', 80);
$this->RegisterVariableString('Stoertext', 'Stoertext', '', 90);
}
}
+3 -2
View File
@@ -7,10 +7,11 @@ namespace Belevo\EnelixEMS;
interface VerbraucherSchnittstelle
{
/**
* Empfaengt die aktuelle Leistungsvorgabe des Managers.
* Empfaengt Kopf und Sollleistung_W als vollstaendigen Managerdatensatz.
*
* Dies ist die einzige fachliche Empfangsmethode des Verbrauchers.
*
* @param array<string, mixed> $daten
*/
public function ManagerdatenEmpfangen(array $daten): void;
}
+235
View File
@@ -0,0 +1,235 @@
<?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 restzeitBisLastwechsel(
int $letzterLastwechsel,
int $jetzt,
int $sperrzeitSekunden
): int {
if ($sperrzeitSekunden < 0) {
throw new InvalidArgumentException('Die Lastwechsel-Sperrzeit darf nicht negativ sein.');
}
if ($letzterLastwechsel <= 0) {
return 0;
}
$verstrichen = max(0, $jetzt - $letzterLastwechsel);
return max(0, $sperrzeitSekunden - $verstrichen);
}
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{0: float, 1: float}
*/
public static function legionellenTemperaturgrenzen(
bool $aktiv,
int $sekundenSeitAbschluss,
int $minimalintervallTage,
int $maximalintervallTage,
float $mindesttemperatur,
float $maximaltemperatur,
float $legionellentemperatur
): array {
if (!$aktiv) {
return [$mindesttemperatur, $maximaltemperatur];
}
if ($sekundenSeitAbschluss >= $minimalintervallTage * 86400) {
$maximaltemperatur = $legionellentemperatur;
}
if ($sekundenSeitAbschluss >= $maximalintervallTage * 86400) {
$mindesttemperatur = $legionellentemperatur;
}
return [$mindesttemperatur, $maximaltemperatur];
}
/**
* @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);
}
}
+68
View File
@@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
namespace Belevo\EnelixEMS\Tests;
use PHPUnit\Framework\TestCase;
final class DokumentationsstrukturTest extends TestCase
{
/** @return list<array{string}> */
public function modulReadmes(): array
{
return [
['Batterie'],
['Pufferspeicher'],
['Waermepumpe'],
['Ladestation-Stand-Alone'],
['Ladestation-Gateway'],
['Easee-Gateway'],
];
}
/** @dataProvider modulReadmes */
public function testJederModulentwurfHatEinReadme(string $modul): void
{
$pfad = __DIR__ . '/../docs/module/' . $modul . '/README.md';
self::assertFileExists($pfad);
$inhalt = file_get_contents($pfad);
self::assertNotFalse($inhalt);
self::assertStringContainsString('Status: Diskussionsentwurf', $inhalt);
self::assertStringContainsString('Properties', $inhalt);
}
public function testSchnittstellenDokumentiertSind(): void
{
self::assertFileExists(__DIR__ . '/../docs/Schnittstelle.md');
self::assertFileExists(__DIR__ . '/../docs/Obere-Anschluesse.md');
self::assertFileExists(__DIR__ . '/../libs/VerbraucherBasisTrait.php');
}
public function testManagerDokumentiertDenImplementiertenStand(): void
{
$inhalt = file_get_contents(__DIR__ . '/../docs/module/Manager/README.md');
self::assertNotFalse($inhalt);
self::assertStringContainsString('Status: Implementiert', $inhalt);
self::assertStringContainsString('Verteilalgorithmus', $inhalt);
}
public function testVerbraucherEinStufigDokumentiertDenImplementiertenStand(): void
{
$inhalt = file_get_contents(__DIR__ . '/../docs/module/Verbraucher-1-Stufig/README.md');
self::assertNotFalse($inhalt);
self::assertStringContainsString('Status: implementiert', $inhalt);
self::assertStringContainsString('Umschaltabstand', $inhalt);
self::assertStringContainsString('Installation und Inbetriebnahme', $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);
}
}
+115
View File
@@ -0,0 +1,115 @@
<?php
declare(strict_types=1);
namespace Belevo\EnelixEMS\Tests;
use Belevo\EnelixEMS\EinStufigRegler;
use DateTimeImmutable;
use DateTimeZone;
use InvalidArgumentException;
use PHPUnit\Framework\TestCase;
final class EinStufigReglerTest extends TestCase
{
public function testFreiesLeistungsangebotEnthaeltAusUndEin(): void
{
self::assertSame(
[0, 3500],
EinStufigRegler::leistungsangebot(3500, 0, true, true, false)
);
}
public function testGesperrtesOderErzwungenesAngebotEnthaeltNurIstleistung(): void
{
self::assertSame(
[3500],
EinStufigRegler::leistungsangebot(3500, 3500, true, false, false)
);
self::assertSame(
[0],
EinStufigRegler::leistungsangebot(3500, 0, true, true, true)
);
self::assertSame(
[3500],
EinStufigRegler::leistungsangebot(3500, 3500, false, true, false)
);
}
public function testUngueltigeIstleistungWirdAbgewiesen(): void
{
$this->expectException(InvalidArgumentException::class);
EinStufigRegler::leistungsangebot(3500, 1200, true, true, false);
}
public function testMindestlaufzeitWirdErstZumSpaetestmoeglichenStartErzwungen(): void
{
self::assertFalse(EinStufigRegler::mussMindestlaufzeitErzwingen(0, 3600, 7200));
self::assertTrue(EinStufigRegler::mussMindestlaufzeitErzwingen(0, 3600, 3600));
self::assertTrue(EinStufigRegler::mussMindestlaufzeitErzwingen(1800, 3600, 1200));
self::assertFalse(EinStufigRegler::mussMindestlaufzeitErzwingen(3600, 3600, 1));
}
public function testNaechsteTagespruefungIstEreignisbasiert(): void
{
self::assertSame(
3600,
EinStufigRegler::sekundenBisNaechsteTagespruefung(0, 3600, 7200, false)
);
self::assertSame(
1800,
EinStufigRegler::sekundenBisNaechsteTagespruefung(1800, 3600, 7200, true)
);
self::assertSame(
7200,
EinStufigRegler::sekundenBisNaechsteTagespruefung(3600, 3600, 7200, false)
);
}
public function testLaufzeitWirdInnerhalbEinesTagesFortgeschrieben(): void
{
$zone = new DateTimeZone('Europe/Zurich');
$von = (new DateTimeImmutable('2026-09-17 10:00:00', $zone))->getTimestamp();
$bis = (new DateTimeImmutable('2026-09-17 10:02:30', $zone))->getTimestamp();
self::assertSame(
['Tag' => '2026-09-17', 'Sekunden' => 250, 'StandZeit' => $bis],
EinStufigRegler::aktualisiereTageslaufzeit(
'2026-09-17',
100,
$von,
$bis,
true,
$zone
)
);
}
public function testLaufzeitWirdAnLokalerTagesgrenzeGetrennt(): void
{
$zone = new DateTimeZone('Europe/Zurich');
$von = (new DateTimeImmutable('2026-09-16 23:59:50', $zone))->getTimestamp();
$bis = (new DateTimeImmutable('2026-09-17 00:00:10', $zone))->getTimestamp();
self::assertSame(
['Tag' => '2026-09-17', 'Sekunden' => 10, 'StandZeit' => $bis],
EinStufigRegler::aktualisiereTageslaufzeit(
'2026-09-16',
1000,
$von,
$bis,
true,
$zone
)
);
}
public function testTagesendeBeruecksichtigtSommerzeit(): void
{
$zone = new DateTimeZone('Europe/Zurich');
$zeitpunkt = (new DateTimeImmutable('2026-03-29 00:00:00', $zone))->getTimestamp();
self::assertSame(23 * 3600, EinStufigRegler::sekundenBisTagesende($zeitpunkt, $zone));
}
}
+1
View File
@@ -15,6 +15,7 @@ final class LibraryMetadataTest extends TestCase
$daten = json_decode($inhalt, true, 512, JSON_THROW_ON_ERROR);
self::assertSame('Enelix EMS', $daten['name']);
self::assertSame('Enelix', $daten['author']);
self::assertSame('8.0', $daten['compatibility']['version']);
self::assertMatchesRegularExpression('/^\{[0-9A-F-]{36}\}$/', $daten['id']);
}
+117
View File
@@ -0,0 +1,117 @@
<?php
declare(strict_types=1);
namespace Belevo\EnelixEMS\Tests;
use PHPUnit\Framework\TestCase;
final class ManagerModulstrukturTest extends TestCase
{
public function testManagerIstAlsSymconModulVollstaendig(): void
{
foreach (['module.json', 'form.json', 'module.php'] as $datei) {
self::assertFileExists(__DIR__ . '/../Manager/' . $datei);
}
$metadaten = json_decode(
(string) file_get_contents(__DIR__ . '/../Manager/module.json'),
true,
512,
JSON_THROW_ON_ERROR
);
self::assertSame('Manager', $metadaten['name']);
self::assertSame('Enelix', $metadaten['vendor']);
self::assertSame(['Manager'], $metadaten['aliases']);
self::assertSame(3, $metadaten['type']);
self::assertMatchesRegularExpression('/^\{[0-9A-F-]{36}\}$/', $metadaten['id']);
}
public function testFormularEnthaeltDieZentralenManagerProperties(): void
{
$formular = json_decode(
(string) file_get_contents(__DIR__ . '/../Manager/form.json'),
true,
512,
JSON_THROW_ON_ERROR
);
$json = json_encode($formular, JSON_THROW_ON_ERROR);
foreach ([
'NetzleistungVariableID',
'VerbraucherZuordnung',
'AutomatischeVerbraucherZuordnung',
'Lastspitzenmodus',
'Monatsgrenzen',
'SollwertSolarladen',
'KeepAlive',
'VerbraucherTimeout',
'MesswertMaxAlter',
'Umschaltdifferenz',
'DiagnosevariablenAnzeigen',
'LoggingEin',
'VerbraucherAktualisieren',
] as $property) {
self::assertStringContainsString($property, $json);
}
foreach (['Regelintervall', 'Vorgabeintervall', 'UmschaltschwelleUeberschuss',
'UmschaltschwelleLastspitze', 'Optionale obere Anschluesse'] as $entfernt
) {
self::assertStringNotContainsString($entfernt, $json);
}
foreach (['Grundeinstellungen', 'Erweiterte Einstellungen', 'Prognose / Forecast',
'SDL / VGT', 'Lizenzierung', 'Stoerueberwachung'] as $bereich
) {
self::assertStringContainsString($bereich, $json);
}
self::assertSame('Lizenzierung', $formular['elements'][0]['caption']);
self::assertStringContainsString(
"'SuchbereichID' => \$SuchbereichID",
$json
);
self::assertStringContainsString(
"'Zuordnung' => \$AutomatischeVerbraucherZuordnung",
$json
);
}
public function testManagerTrenntDiagnosevariablenUndDebugLogging(): void
{
$inhalt = (string) file_get_contents(__DIR__ . '/../Manager/module.php');
self::assertStringContainsString(
"RegisterPropertyBoolean('DiagnosevariablenAnzeigen', false)",
$inhalt
);
self::assertStringContainsString("ReadPropertyBoolean('DiagnosevariablenAnzeigen')", $inhalt);
self::assertStringContainsString('UnregisterVariable($ident)', $inhalt);
self::assertStringContainsString("ReadPropertyBoolean('LoggingEin')", $inhalt);
self::assertStringContainsString('SendDebug(', $inhalt);
}
public function testManagerVerwendetDieNeuenZeitdefaults(): void
{
$inhalt = (string) file_get_contents(__DIR__ . '/../Manager/module.php');
self::assertStringContainsString("RegisterPropertyInteger('KeepAlive', 60)", $inhalt);
self::assertStringContainsString("RegisterPropertyInteger('VerbraucherTimeout', 60)", $inhalt);
self::assertStringContainsString("RegisterPropertyInteger('MesswertMaxAlter', 60)", $inhalt);
self::assertStringContainsString("RegisterPropertyFloat('Umschaltdifferenz', 5.0)", $inhalt);
self::assertStringNotContainsString("RegisterPropertyInteger('Regelintervall'", $inhalt);
self::assertStringNotContainsString("RegisterPropertyInteger('Vorgabeintervall'", $inhalt);
$verbraucherBasis = (string) file_get_contents(__DIR__ . '/../libs/VerbraucherBasisTrait.php');
self::assertStringContainsString(
"RegisterPropertyInteger('VorgabeTimeout', 120)",
$verbraucherBasis
);
}
public function testManagerImplementiertDenGemeinsamenEmpfangsvertrag(): void
{
$inhalt = (string) file_get_contents(__DIR__ . '/../Manager/module.php');
self::assertStringContainsString('implements ManagerSchnittstelle', $inhalt);
self::assertStringContainsString('VerbraucherdatenEmpfangen(array $daten)', $inhalt);
self::assertStringContainsString("'ManagerdatenEmpfangen'", $inhalt);
}
}
+187
View File
@@ -0,0 +1,187 @@
<?php
declare(strict_types=1);
namespace Belevo\EnelixEMS\Tests;
use Belevo\EnelixEMS\ManagerRegler;
use Belevo\EnelixEMS\Nachrichtenvertrag;
use InvalidArgumentException;
use PHPUnit\Framework\TestCase;
final class ManagerReglerTest extends TestCase
{
public function testOhnePeakBegrenzungBleibtSolarladenAktiv(): void
{
self::assertSame(
ManagerRegler::BETRIEBSART_PV,
ManagerRegler::bestimmeBetriebsart(
true,
true,
25000.0,
ManagerRegler::BETRIEBSART_INAKTIV,
false,
0.0,
15000.0,
0.0
)
);
}
public function testBetriebsartBleibtZwischenDenZielwertenErhalten(): void
{
self::assertSame(
ManagerRegler::BETRIEBSART_PEAK,
ManagerRegler::bestimmeBetriebsart(
true,
true,
5000.0,
ManagerRegler::BETRIEBSART_PEAK,
true,
0.0,
12000.0,
0.0
)
);
}
public function testUmschaltdifferenzVerhindertZuKleineAenderung(): void
{
self::assertSame(
ManagerRegler::BETRIEBSART_PV,
ManagerRegler::bestimmeBetriebsart(
true,
true,
11000.0,
ManagerRegler::BETRIEBSART_PV,
true,
10000.0,
10500.0,
60.0
)
);
}
public function testWechseltOberhalbDerPeakgrenzeInDenPeakbetrieb(): void
{
self::assertSame(
ManagerRegler::BETRIEBSART_PEAK,
ManagerRegler::bestimmeBetriebsart(
true,
true,
12500.0,
ManagerRegler::BETRIEBSART_PV,
true,
0.0,
12000.0,
5.0
)
);
}
public function testWechseltUnterhalbDesSolarsollwertsInDenPvBetrieb(): void
{
self::assertSame(
ManagerRegler::BETRIEBSART_PV,
ManagerRegler::bestimmeBetriebsart(
true,
true,
-500.0,
ManagerRegler::BETRIEBSART_PEAK,
true,
0.0,
12000.0,
5.0
)
);
}
public function testVerteilungBeruecksichtigtPrioritaetEinzelwerteUndBereiche(): void
{
$ergebnis = ManagerRegler::verteile(
0.0,
4500.0,
[
$this->verbraucher(20, 5, [0, 3000], 0),
$this->verbraucher(10, 0, [0, ['Von_W' => 1000, 'Bis_W' => 2000], 4000], 0),
],
'PrioritaetPV'
);
self::assertSame(4500, $ergebnis['Verteilbudget_W']);
self::assertSame([10 => 4000, 20 => 0], $ergebnis['Sollwerte']);
self::assertSame(500, $ergebnis['Restabweichung_W']);
}
public function testNichtAenderbarerVerbraucherReserviertSeineIstleistung(): void
{
$fest = $this->verbraucher(10, 0, [0, 3000], 3000, false);
$steuerbar = $this->verbraucher(20, 1, [0, ['Von_W' => 1, 'Bis_W' => 4000]], 0);
$ergebnis = ManagerRegler::verteile(3000.0, 5000.0, [$fest, $steuerbar], 'PrioritaetPV');
self::assertSame(3000, $ergebnis['NichtSteuerbar_W']);
self::assertSame([20 => 2000], $ergebnis['Sollwerte']);
self::assertSame(0, $ergebnis['Restabweichung_W']);
}
public function testFehlendeIstleistungFaelltAufAngenommenenSollwertZurueck(): void
{
$verbraucher = $this->verbraucher(10, 0, [0, 3000], null, false);
$verbraucher['Daten']['Leistungsquelle'] = Nachrichtenvertrag::LEISTUNGSQUELLE_NICHT_VORHANDEN;
$verbraucher['Daten']['Istleistung_W'] = null;
$verbraucher['Daten']['Zustand'][0]['Wert'] = 3000;
$ergebnis = ManagerRegler::verteile(3000.0, 0.0, [$verbraucher], 'PrioritaetPV');
self::assertSame(3000, $ergebnis['AktuelleVerbraucherleistung_W']);
self::assertSame(-3000, $ergebnis['Restabweichung_W']);
}
public function testMonatlicheGrenzeMussVollstaendigSein(): void
{
$this->expectException(InvalidArgumentException::class);
ManagerRegler::wirksameLastspitzengrenze(2, 0.0, [1 => 12000.0], 9);
}
/**
* @param list<int|array{Von_W: int, Bis_W: int}> $leistungswerte
* @return array{InstanzID: int, Daten: array<string, mixed>}
*/
private function verbraucher(
int $instanzID,
int $prioritaet,
array $leistungswerte,
?int $istleistung,
bool $aenderungMoeglich = true
): array {
$quelle = $istleistung === null
? Nachrichtenvertrag::LEISTUNGSQUELLE_NICHT_VORHANDEN
: Nachrichtenvertrag::LEISTUNGSQUELLE_GEMESSEN;
return [
'InstanzID' => $instanzID,
'Daten' => [
'Kopf' => [
'Version' => Nachrichtenvertrag::VERSION,
'AbsenderID' => $instanzID,
'EmpfaengerID' => 1,
'Zeitpunkt' => 1788825600,
],
'PrioritaetPV' => $prioritaet,
'PrioritaetPeak' => $prioritaet,
'Leistungswerte_W' => $leistungswerte,
'AenderungMoeglich' => $aenderungMoeglich,
'Verfuegbar' => true,
'Istleistung_W' => $istleistung,
'Leistungsquelle' => $quelle,
'Zustand' => [[
'Kennung' => 'Sollleistung_W',
'Art' => 'Sollwert',
'Wert' => $istleistung,
'Einheit' => 'W',
]],
],
];
}
}
+106
View File
@@ -0,0 +1,106 @@
<?php
declare(strict_types=1);
namespace Belevo\EnelixEMS\Tests;
use Belevo\EnelixEMS\VerbraucherBasisTrait;
use PHPUnit\Framework\TestCase;
final class VerbraucherBasisTraitTest extends TestCase
{
public function testGemeinsamePropertiesUndVariablenWerdenRegistriert(): void
{
$modul = new VerbraucherBasisDummy();
$modul->registriere();
self::assertSame(
[
'PrioritaetPV',
'PrioritaetPeak',
'Meldeintervall',
'VorgabeTimeout',
'EinstellungenInVisu',
'LoggingEin',
],
array_keys($modul->properties)
);
self::assertSame(['Aktiv'], array_keys($modul->variablen));
self::assertSame(['Aktiv'], $modul->aktionen);
$modul->registriereDiagnose();
self::assertSame(
[
'Aktiv',
'Istleistung',
'Leistungsquelle',
'Sollleistung',
'SollwertGueltig',
'Verfuegbar',
'AenderungMoeglich',
'Stoerung',
'Stoertext',
],
array_keys($modul->variablen)
);
}
}
final class VerbraucherBasisDummy
{
use VerbraucherBasisTrait;
/** @var array<string, mixed> */
public array $properties = [];
/** @var array<string, string> */
public array $variablen = [];
/** @var list<string> */
public array $aktionen = [];
public function registriere(): void
{
$this->registriereVerbraucherBasis();
}
public function registriereDiagnose(): void
{
$this->registriereVerbraucherDiagnose();
}
public function RegisterPropertyInteger(string $ident, int $standard): void
{
$this->properties[$ident] = $standard;
}
public function RegisterPropertyBoolean(string $ident, bool $standard): void
{
$this->properties[$ident] = $standard;
}
public function RegisterVariableBoolean(string $ident, string $name, string $profil, int $position): void
{
$this->variablen[$ident] = 'Boolean';
}
public function RegisterVariableFloat(string $ident, string $name, string $profil, int $position): void
{
$this->variablen[$ident] = 'Float';
}
public function RegisterVariableInteger(string $ident, string $name, string $profil, int $position): void
{
$this->variablen[$ident] = 'Integer';
}
public function RegisterVariableString(string $ident, string $name, string $profil, int $position): void
{
$this->variablen[$ident] = 'String';
}
public function EnableAction(string $ident): void
{
$this->aktionen[] = $ident;
}
}
@@ -0,0 +1,144 @@
<?php
declare(strict_types=1);
namespace Belevo\EnelixEMS\Tests;
use PHPUnit\Framework\TestCase;
final class VerbraucherEinStufigModulstrukturTest extends TestCase
{
private const MODULPFAD = __DIR__ . '/../VerbraucherEinStufig/';
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('VerbraucherEinStufig', $metadaten['name']);
self::assertSame('Enelix', $metadaten['vendor']);
self::assertSame(['Verbraucher 1-Stufig'], $metadaten['aliases']);
self::assertSame(3, $metadaten['type']);
self::assertMatchesRegularExpression('/^\{[0-9A-F-]{36}\}$/', $metadaten['id']);
}
public function testFormularEnthaeltDieModulstatus(): void
{
$formular = json_decode(
(string) file_get_contents(self::MODULPFAD . 'form.json'),
true,
512,
JSON_THROW_ON_ERROR
);
self::assertSame([102, 201, 202], array_column($formular['status'], 'code'));
}
public function testPropertiesSindRegistriertUndImFormularVorhanden(): 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::assertSame(
[
'PrioritaetPV',
'PrioritaetPeak',
'Meldeintervall',
'VorgabeTimeout',
'EinstellungenInVisu',
'LoggingEin',
'Nennleistung',
'SchaltkontaktVariableID',
'SchaltkontaktInvertiert',
'RueckmeldungVariableID',
'Mindestlaufzeit',
'Umschaltabstand',
'DiagnosevariablenAnzeigen',
],
$properties
);
$formular = (string) file_get_contents(self::MODULPFAD . 'form.json');
foreach ($properties as $property) {
self::assertStringContainsString('"name": "' . $property . '"', $formular, $property);
}
}
public function testNurUmschaltabstandErsetztAlteZyklusProperties(): void
{
$modul = (string) file_get_contents(self::MODULPFAD . 'module.php');
self::assertStringContainsString(
"RegisterPropertyInteger('Umschaltabstand', 5)",
$modul
);
self::assertStringNotContainsString("RegisterPropertyInteger('Interval'", $modul);
self::assertStringNotContainsString("RegisterPropertyInteger('IdleCounterMax'", $modul);
self::assertStringNotContainsString("RegisterTimer('Regelzyklus'", $modul);
}
public function testRegelungReagiertAufEreignisseUndEinmalTimer(): void
{
$modul = (string) file_get_contents(self::MODULPFAD . 'module.php');
self::assertStringContainsString('public function MessageSink', $modul);
self::assertStringContainsString('RegisterMessage($neueID, self::VM_UPDATE)', $modul);
foreach (['Umschaltfreigabe', 'VorgabeTimeout', 'Tagesplanung'] as $timer) {
self::assertStringContainsString("'$timer'", $modul);
}
self::assertStringContainsString(
"SetTimerInterval('Meldezyklus', \$this->ReadPropertyInteger('Meldeintervall') * 1000)",
$modul
);
}
public function testModulVerwendetAusschliesslichDenVertrag30(): void
{
$modul = (string) file_get_contents(self::MODULPFAD . 'module.php');
self::assertStringContainsString('implements VerbraucherSchnittstelle', $modul);
self::assertStringContainsString('ManagerdatenEmpfangen(array $daten)', $modul);
self::assertStringContainsString('Nachrichtenvertrag::pruefeManagerdaten', $modul);
self::assertStringContainsString('Nachrichtenvertrag::pruefeVerbraucherdaten', $modul);
self::assertStringNotContainsString("RegisterVariableString('PowerSteps'", $modul);
self::assertStringNotContainsString("RegisterPropertyInteger('ManagerID'", $modul);
}
public function testFachvariablenUndRueckmeldestoerungSindEnthalten(): void
{
$modul = (string) file_get_contents(self::MODULPFAD . 'module.php');
self::assertStringContainsString(
"RegisterVariableBoolean('Schaltzustand'",
$modul
);
self::assertStringContainsString(
"RegisterVariableInteger('Tageslaufzeit'",
$modul
);
self::assertStringContainsString("'Kennung' => 'Rueckmeldefehler'", $modul);
self::assertStringContainsString("'Kennung' => 'Tageslaufzeit_s'", $modul);
}
public function testDiagnosevariablenSindOptional(): void
{
$modul = (string) file_get_contents(self::MODULPFAD . 'module.php');
self::assertStringContainsString("ReadPropertyBoolean('DiagnosevariablenAnzeigen')", $modul);
self::assertStringContainsString('registriereVerbraucherDiagnose', $modul);
self::assertStringContainsString('UnregisterVariable($ident)', $modul);
}
}
+110
View File
@@ -0,0 +1,110 @@
<?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 testLastwechselRestzeitWirdInSekundenBerechnet(): void
{
self::assertSame(0, WarmwasserRegler::restzeitBisLastwechsel(0, 100, 5));
self::assertSame(5, WarmwasserRegler::restzeitBisLastwechsel(100, 100, 5));
self::assertSame(2, WarmwasserRegler::restzeitBisLastwechsel(100, 103, 5));
self::assertSame(0, WarmwasserRegler::restzeitBisLastwechsel(100, 105, 5));
}
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']);
}
public function testLegionellenschaltungHebtTemperaturgrenzenZweistufigAn(): void
{
self::assertSame(
[45.0, 60.0],
WarmwasserRegler::legionellenTemperaturgrenzen(true, 3 * 86400, 4, 7, 45.0, 60.0, 65.0)
);
self::assertSame(
[45.0, 65.0],
WarmwasserRegler::legionellenTemperaturgrenzen(true, 4 * 86400, 4, 7, 45.0, 60.0, 65.0)
);
self::assertSame(
[65.0, 65.0],
WarmwasserRegler::legionellenTemperaturgrenzen(true, 7 * 86400, 4, 7, 45.0, 60.0, 65.0)
);
self::assertSame(
[45.0, 60.0],
WarmwasserRegler::legionellenTemperaturgrenzen(false, 8 * 86400, 4, 7, 45.0, 60.0, 65.0)
);
}
}
@@ -0,0 +1,149 @@
<?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('Enelix', $metadaten['vendor']);
self::assertSame(['Wassererwärmer'], $metadaten['aliases']);
self::assertSame(3, $metadaten['type']);
self::assertMatchesRegularExpression('/^\{[0-9A-F-]{36}\}$/', $metadaten['id']);
}
public function testAlle19PropertiesSindRegistriertUndImFormularDokumentiert(): 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(19, $properties);
$formular = (string) file_get_contents(self::MODULPFAD . 'form.json');
foreach ($properties as $property) {
self::assertStringContainsString('"name": "' . $property . '"', $formular, $property);
}
}
public function testRegelungIstEreignisbasiertUndSperrtLastwechselFuenfSekunden(): void
{
$inhalt = (string) file_get_contents(self::MODULPFAD . 'module.php');
self::assertStringContainsString("RegisterPropertyInteger('LastwechselSperrzeit', 5)", $inhalt);
self::assertStringNotContainsString("RegisterPropertyInteger('Interval'", $inhalt);
self::assertStringNotContainsString("RegisterPropertyInteger('IdleCounterMax'", $inhalt);
self::assertStringNotContainsString("ReadPropertyInteger('Interval')", $inhalt);
self::assertStringContainsString("'LastwechselFreigabe'", $inhalt);
self::assertStringContainsString('LetzteTemperaturberechnung', $inhalt);
self::assertStringContainsString('$angebot = [$aktuelleLeistung];', $inhalt);
self::assertStringNotContainsString("RegisterVariableBoolean('Idle'", $inhalt);
self::assertStringNotContainsString("RegisterVariableInteger('IdleCounter'", $inhalt);
self::assertStringNotContainsString('ZustandIdle', $inhalt);
self::assertStringContainsString("['Idle', 'IdleCounter']", $inhalt);
}
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);
self::assertStringContainsString("'AutomatischeVerbraucherZuordnung'", $inhalt);
self::assertStringContainsString("'VerbraucherZuordnung'", $inhalt);
}
public function testVierzehnDiagnosevariablenWerdenBedarfsgesteuertAngelegtUndGeloescht(): 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(14, $variablen[1]);
foreach (['Istleistung', 'Sollleistung', 'Stoerung', 'Stoertext', 'AktiveStufe', 'BezogeneEnergie'] as $ident) {
self::assertContains($ident, $variablen[1]);
self::assertStringContainsString("Zustand" . $ident, $inhalt);
}
self::assertStringContainsString("ReadPropertyBoolean('DiagnosevariablenAnzeigen')", $inhalt);
self::assertStringContainsString('registriereVerbraucherDiagnose', $inhalt);
self::assertStringContainsString('UnregisterVariable($ident)', $inhalt);
self::assertStringContainsString("ReadPropertyBoolean('LoggingEin')", $inhalt);
self::assertStringNotContainsString("case 'LogStatus':", $inhalt);
}
public function testLegionellentemperaturWirdNurBeiAktiverFunktionAngelegt(): void
{
$inhalt = (string) file_get_contents(self::MODULPFAD . 'module.php');
self::assertStringContainsString("ReadPropertyBoolean('LegionellenfunktionAktiv')", $inhalt);
self::assertStringContainsString(
"RegisterVariableFloat('Legionellentemperatur'",
$inhalt
);
self::assertStringContainsString("UnregisterVariable('Legionellentemperatur')", $inhalt);
self::assertStringContainsString("ZustandLegionellentemperatur", $inhalt);
}
public function testFormularIstWieGewuenschtGegliedertUndHatKeineAktionsknoepfe(): void
{
$formular = json_decode(
(string) file_get_contents(self::MODULPFAD . 'form.json'),
true,
512,
JSON_THROW_ON_ERROR
);
self::assertSame(
[
'Manager und Zeitverhalten',
'Speichereinstellungen',
'Erweiterte Speichereinstellungen',
'Erweiterte sonstige Einstellungen',
],
array_column($formular['elements'], 'caption')
);
self::assertArrayNotHasKey('actions', $formular);
}
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);
}
}