51 Commits
Author SHA1 Message Date
dh e6516f5d7a docs: add field-test location decision
Tests / test (push) Successful in 43s
2026-09-22 05:17:58 +00:00
dh 8ae1f1ea8a docs: add open product decisions
Tests / test (push) Successful in 47s
2026-09-22 05:03:49 +00:00
dh 3c71626b5f Verbrauchermodule und betriebsartabhängige Angebote ergänzen
Tests / test (push) Successful in 42s
2026-09-21 18:46:17 +00:00
dh 7b6d1b2f07 Manager-Lizenzierung integrieren
Tests / test (push) Successful in 1m7s
2026-09-20 14:21:11 +00:00
dh cf1ae5e348 Fix native demo tiles
Tests / test (push) Successful in 44s
2026-09-17 14:34:24 +00:00
dh 3edf724df7 Use explicit demo timer action
Tests / test (push) Successful in 39s
2026-09-17 14:11:00 +00:00
dh 7cf67d4392 Erweitere explizite EMS-Modultests
Tests / test (push) Successful in 42s
2026-09-17 14:07:52 +00:00
dh abd8c7edeb Enhance demo visualization and charts
Tests / test (push) Successful in 47s
2026-09-17 14:06:12 +00:00
dh 312b76aee5 Add interactive Enelix demo system
Tests / test (push) Successful in 42s
2026-09-17 13:41:46 +00:00
dh 4a01e9df0f Standardisiere Symcon-Modultests
Tests / test (push) Successful in 40s
2026-09-17 12:36:37 +00:00
dh 66966875e7 docs: falsch abgelegten VGT-Punkt entfernen
Tests / test (push) Successful in 42s
2026-09-17 12:14:50 +00:00
dh 51ce9b339e docs: VGT-Verfügbarkeit für Beta vormerken
Tests / test (push) Successful in 41s
2026-09-17 12:05:39 +00:00
dh 37822f0ed0 docs: add intake quality gate
Tests / test (push) Successful in 41s
2026-09-17 11:53:22 +00:00
dh cda579ab86 docs: simplify license server decision
Tests / test (push) Successful in 42s
2026-09-17 11:45:48 +00:00
dh d874ec0a3b docs: add central open decisions list
Tests / test (push) Successful in 43s
2026-09-17 11:23:54 +00:00
dh 5c5ef73fb8 Verbraucher 1-Stufig um Mindestzeiten erweitern
Tests / test (push) Successful in 42s
2026-09-17 10:23:39 +00:00
dh 49fe18c2eb Wassererwaermer umfassend dokumentieren
Tests / test (push) Failing after 41s
2026-09-17 09:55:28 +00:00
dh 052685401f Automatische Verbrauchererkennung absichern
Tests / test (push) Failing after 37s
2026-09-17 09:46:01 +00:00
dh ecbd0a3baf Doppelte Verbraucher-Modul-ID entfernen
Tests / test (push) Failing after 39s
2026-09-17 09:44:44 +00:00
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
84 changed files with 14109 additions and 41 deletions
+1 -1
View File
@@ -21,6 +21,6 @@ jobs:
- name: Abhaengigkeiten installieren - name: Abhaengigkeiten installieren
run: composer install --no-interaction --no-progress --prefer-dist run: composer install --no-interaction --no-progress --prefer-dist
- name: Pruefungen ausfuehren - name: Unit-Tests und Syntaxpruefung ausfuehren
run: composer check run: composer check
+1
View File
@@ -1,3 +1,4 @@
/vendor/ /vendor/
/.phpunit.cache/ /.phpunit.cache/
/.phpunit.result.cache /.phpunit.result.cache
/build/
+155
View File
@@ -0,0 +1,155 @@
{
"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": "Abfrageintervall",
"caption": "Geraetestatus abfragen",
"suffix": " s",
"minimum": 1
}
]
},
{
"type": "ExpansionPanel",
"caption": "Ladestation",
"expanded": true,
"items": [
{
"type": "Select",
"name": "Geraetetyp",
"caption": "Geraetetyp",
"options": [
{
"caption": "Nicht konfiguriert",
"value": 0
},
{
"caption": "go-e Charger (alte API)",
"value": 1
},
{
"caption": "go-e Charger Gemini / Gemini flex",
"value": 2
},
{
"caption": "smart-me Pico",
"value": 3
}
]
},
{
"type": "ValidationTextBox",
"name": "Geraeteadresse",
"caption": "IP-Adresse oder Hostname (go-e)"
},
{
"type": "ValidationTextBox",
"name": "GeraeteID",
"caption": "Geraete-ID (Pico)"
},
{
"type": "ValidationTextBox",
"name": "Seriennummer",
"caption": "Seriennummer (Pico)"
},
{
"type": "ValidationTextBox",
"name": "Benutzername",
"caption": "Benutzername (Pico)"
},
{
"type": "PasswordTextBox",
"name": "Passwort",
"caption": "Passwort (Pico)"
},
{
"type": "NumberSpinner",
"name": "MaximalerLadestrom",
"caption": "Maximaler Ladestrom",
"suffix": " A",
"minimum": 6,
"maximum": 32
},
{
"type": "CheckBox",
"name": "Ladefreigabe",
"caption": "Ladefreigabe beim Start"
},
{
"type": "CheckBox",
"name": "Solarladen",
"caption": "Solarladen beim Start"
}
]
},
{
"type": "ExpansionPanel",
"caption": "Darstellung und Diagnose",
"items": [
{
"type": "CheckBox",
"name": "EinstellungenInVisu",
"caption": "Ladefreigabe und Solarladen 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": "Geraetekommunikation fehlgeschlagen"
}
]
}
+14
View File
@@ -0,0 +1,14 @@
{
"id": "{0D94913C-0F31-4C29-A685-6EB4AE58E55D}",
"name": "LadestationStandAlone",
"type": 3,
"vendor": "Enelix",
"aliases": [
"Ladestation Stand-Alone"
],
"parentRequirements": [],
"childRequirements": [],
"implemented": [],
"prefix": "ENELIX",
"url": "https://git.belevo.ch/ENELIX/Enelix-EMS/src/branch/develop/LadestationStandAlone"
}
+763
View File
@@ -0,0 +1,763 @@
<?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/LadestationAdapter.php';
require_once __DIR__ . '/../libs/LadestationRegler.php';
use Belevo\EnelixEMS\LadestationAdapter;
use Belevo\EnelixEMS\LadestationRegler;
use Belevo\EnelixEMS\LadestationTransport;
use Belevo\EnelixEMS\Nachrichtenvertrag;
use Belevo\EnelixEMS\VerbraucherBasisTrait;
use Belevo\EnelixEMS\VerbraucherSchnittstelle;
class LadestationStandAlone 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_KOMMUNIKATIONSFEHLER = 202;
/** @var list<string> */
private const DIAGNOSE_VARIABLEN = [
'Istleistung',
'Leistungsquelle',
'Sollleistung',
'SollwertGueltig',
'Verfuegbar',
'AenderungMoeglich',
'Stoerung',
'Stoertext',
'LetzterGeraetebefehl',
'LeistungsangebotDiagnose',
];
public function Create(): void
{
parent::Create();
$this->registriereVerbraucherBasis();
$this->RegisterPropertyInteger('Geraetetyp', 0);
$this->RegisterPropertyString('Geraeteadresse', '');
$this->RegisterPropertyString('GeraeteID', '');
$this->RegisterPropertyString('Seriennummer', '');
$this->RegisterPropertyString('Benutzername', '');
$this->RegisterPropertyString('Passwort', '');
$this->RegisterPropertyInteger('MaximalerLadestrom', 16);
$this->RegisterPropertyInteger('Abfrageintervall', 5);
$this->RegisterPropertyBoolean('Ladefreigabe', false);
$this->RegisterPropertyBoolean('Solarladen', true);
$this->RegisterPropertyBoolean('DiagnosevariablenAnzeigen', false);
// Ausschliesslich fuer den automatisierten Symcon-Funktionstest.
$this->RegisterPropertyBoolean('Testmodus', false);
$this->RegisterPropertyString('Testantwort', '');
$this->RegisterVariableBoolean('FahrzeugVerbunden', 'Fahrzeug verbunden', '~Switch', 100);
$this->RegisterVariableBoolean('FahrzeugGeladen', 'Fahrzeug geladen', '~Switch', 110);
$this->RegisterVariableInteger('Fahrzeugstatus', 'Fahrzeugstatus', '', 120);
$this->RegisterVariableFloat('Ladestrom', 'Ladestrom', '', 130);
$this->RegisterVariableInteger('Phasenzahl', 'Phasenzahl', '', 140);
$this->RegisterAttributeBoolean('InitialwerteGesetzt', false);
$this->RegisterAttributeBoolean('KonfigurationLadefreigabe', false);
$this->RegisterAttributeBoolean('KonfigurationSolarladen', true);
$this->RegisterAttributeBoolean('ZustandLadefreigabe', false);
$this->RegisterAttributeBoolean('ZustandSolarladen', true);
$this->RegisterAttributeBoolean('ZustandFahrzeugVerbunden', false);
$this->RegisterAttributeBoolean('ZustandFahrzeugGeladen', false);
$this->RegisterAttributeInteger('ZustandFahrzeugstatus', 0);
$this->RegisterAttributeFloat('ZustandLadestrom', 0.0);
$this->RegisterAttributeInteger('ZustandPhasenzahl', 0);
$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->RegisterAttributeString('Leistungsangebot', '[0]');
$this->RegisterAttributeString('Betriebsart', Nachrichtenvertrag::BETRIEBSART_PV);
$this->RegisterAttributeInteger('LetzteManagerID', 0);
$this->RegisterAttributeInteger('LetzteVorgabeZeit', 0);
$this->RegisterAttributeInteger('LetzterGesetzterStrom', -1);
$this->RegisterTimer(
'Statuszyklus',
0,
"IPS_RequestAction(\$_IPS['TARGET'], 'Statuszyklus', false);"
);
$this->RegisterTimer(
'Meldezyklus',
0,
"IPS_RequestAction(\$_IPS['TARGET'], 'Melden', false);"
);
$this->RegisterTimer(
'RueckmeldungVerzoegert',
0,
"IPS_RequestAction(\$_IPS['TARGET'], 'Melden', true);"
);
$this->RegisterTimer(
'VorgabeTimeout',
0,
"IPS_RequestAction(\$_IPS['TARGET'], 'VorgabeTimeout', false);"
);
}
public function ApplyChanges(): void
{
parent::ApplyChanges();
$this->initialisiereLokaleEinstellungen();
$this->aktualisiereVariablen();
try {
$this->pruefeKonfiguration();
} catch (Throwable $fehler) {
$this->deaktiviereTimer();
$this->setzeZustand('Verfuegbar', false);
$this->setzeZustand('AenderungMoeglich', false);
$this->setzeStoerung($fehler->getMessage());
$this->SetStatus(self::STATUS_KONFIGURATION_UNGUELTIG);
$this->protokolliere('Konfiguration', $fehler->getMessage());
return;
}
$this->SetTimerInterval('Statuszyklus', $this->ReadPropertyInteger('Abfrageintervall') * 1000);
$this->SetTimerInterval('Meldezyklus', $this->ReadPropertyInteger('Meldeintervall') * 1000);
$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->aktualisiere(true);
return;
case 'Ladefreigabe':
case 'Solarladen':
$this->WriteAttributeBoolean('Zustand' . $ident, (bool) $wert);
$this->setzeSichtbareVariable($ident, (bool) $wert);
$this->setzeZustand('SollwertGueltig', false);
$this->aktualisiere(true);
return;
case 'Statuszyklus':
$this->aktualisiere(true);
return;
case 'VorgabeTimeout':
$this->SetTimerInterval('VorgabeTimeout', 0);
$this->setzeZustand('SollwertGueltig', false);
$this->aktualisiere(true);
return;
case 'Melden':
if ((bool) $wert) {
$this->SetTimerInterval('RueckmeldungVerzoegert', 0);
}
$this->aktualisiere(false);
$this->sendeVerbraucherdaten();
return;
case 'ManagerdatenEmpfangen':
if (!is_string($wert)) {
throw new InvalidArgumentException('Managerdaten muessen als JSON uebergeben werden.');
}
$daten = json_decode($wert, true, 512, JSON_THROW_ON_ERROR);
if (!is_array($daten)) {
throw new InvalidArgumentException('Managerdaten muessen ein JSON-Objekt sein.');
}
$this->ManagerdatenEmpfangen($daten);
return;
}
throw new InvalidArgumentException('Unbekannte Aktion: ' . $ident);
}
/** @param array<string, mixed> $daten */
public function ManagerdatenEmpfangen(array $daten): void
{
Nachrichtenvertrag::pruefeManagerdaten($daten);
if ($daten['Kopf']['EmpfaengerID'] !== $this->InstanceID) {
throw new InvalidArgumentException('Managerdaten sind an eine andere Instanz adressiert.');
}
$managerID = $daten['Kopf']['AbsenderID'];
if (!in_array($managerID, $this->zugeordneteManagerIDs(), true)) {
throw new InvalidArgumentException('Der Manager hat diese Ladestation nicht aktiv zugeordnet.');
}
$this->WriteAttributeString('Betriebsart', $daten['Betriebsart']);
$this->aktualisiere(false, false);
$sollleistung = $daten['Sollleistung_W'];
if ($sollleistung === null) {
if ((bool) $this->leseZustand('SollwertGueltig')
&& !in_array((int) $this->leseZustand('Sollleistung'), $this->leseLeistungsangebot(), true)
) {
$this->setzeZustand('SollwertGueltig', false);
$this->aktualisiere(false, false);
}
$this->SetTimerInterval('RueckmeldungVerzoegert', 100);
return;
}
if (!in_array($sollleistung, $this->leseLeistungsangebot(), true)) {
throw new InvalidArgumentException(
'Sollleistung_W liegt nicht im aktuell gemeldeten Leistungsangebot.'
);
}
$this->WriteAttributeInteger('LetzteManagerID', $managerID);
$this->WriteAttributeInteger('LetzteVorgabeZeit', time());
$this->setzeZustand('Sollleistung', $sollleistung);
$this->setzeZustand('SollwertGueltig', true);
$this->SetTimerInterval('VorgabeTimeout', $this->ReadPropertyInteger('VorgabeTimeout') * 1000);
$this->aktualisiere(false, false);
$this->SetTimerInterval('RueckmeldungVerzoegert', 100);
}
private function aktualisiere(bool $meldungPlanen, bool $statusLesen = true): void
{
try {
if ($statusLesen) {
$this->leseGeraetestatus();
}
$aktiv = (bool) $this->GetValue('Aktiv');
$ladefreigabe = $this->ReadAttributeBoolean('ZustandLadefreigabe');
$verbunden = $this->ReadAttributeBoolean('ZustandFahrzeugVerbunden');
$geladen = $this->ReadAttributeBoolean('ZustandFahrzeugGeladen');
$phasenzahl = $this->ReadAttributeInteger('ZustandPhasenzahl');
$maximalstrom = $this->ReadPropertyInteger('MaximalerLadestrom');
$solarladen = $this->ReadAttributeBoolean('ZustandSolarladen');
$peakbetrieb = $this->ReadAttributeString('Betriebsart')
=== Nachrichtenvertrag::BETRIEBSART_PEAK;
$verfuegbar = LadestationRegler::leistungsangebot(
$aktiv,
$ladefreigabe,
$verbunden,
$geladen,
$phasenzahl,
$maximalstrom,
$solarladen,
false
) !== [0];
$angebot = LadestationRegler::leistungsangebot(
$aktiv,
$ladefreigabe,
$verbunden,
$geladen,
$phasenzahl,
$maximalstrom,
$solarladen,
$peakbetrieb
);
$angebotJson = json_encode($angebot, JSON_THROW_ON_ERROR);
$this->WriteAttributeString('Leistungsangebot', $angebotJson);
$this->setzeSichtbareVariable('LeistungsangebotDiagnose', $angebotJson);
$this->setzeZustand('Verfuegbar', $verfuegbar);
$this->setzeZustand('AenderungMoeglich', $verfuegbar && count($angebot) > 1);
$sollleistung = $this->bestimmeWirksameSollleistung($angebot);
$this->setzeLadestrom($sollleistung);
$this->setzeStoerung('');
$this->SetStatus(self::STATUS_AKTIV);
} catch (Throwable $fehler) {
$this->setzeZustand('Verfuegbar', false);
$this->setzeZustand('AenderungMoeglich', false);
$this->setzeStoerung($fehler->getMessage());
$this->SetStatus(self::STATUS_KOMMUNIKATIONSFEHLER);
$this->protokolliere('Geraetekommunikation', $fehler->getMessage());
}
if ($meldungPlanen) {
$this->SetTimerInterval('RueckmeldungVerzoegert', 100);
}
}
private function leseGeraetestatus(): void
{
$transport = new LadestationTransport(
fn (array $anfrage): string => $this->fuehreHttpAnfrageAus($anfrage)
);
$antwort = $transport->statusLesen(
$this->ReadPropertyInteger('Geraetetyp'),
$this->geraetekonfiguration()
);
$rohstatus = LadestationAdapter::dekodiereStatus(
$this->ReadPropertyInteger('Geraetetyp'),
$antwort
);
$status = LadestationRegler::normalisiereStatus(
$rohstatus,
$this->ReadAttributeBoolean('ZustandFahrzeugVerbunden')
);
foreach ($status as $ident => $wert) {
$this->setzeZustand($ident, $wert);
}
}
/** @param list<int> $angebot */
private function bestimmeWirksameSollleistung(array $angebot): int
{
if ((bool) $this->leseZustand('SollwertGueltig')) {
$sollleistung = (int) $this->leseZustand('Sollleistung');
if (in_array($sollleistung, $angebot, true)) {
return $sollleistung;
}
$this->setzeZustand('SollwertGueltig', false);
}
if (!$this->ReadAttributeBoolean('ZustandSolarladen') && $angebot !== [0]) {
return $angebot[count($angebot) - 1];
}
return 0;
}
private function setzeLadestrom(int $leistung): void
{
$phasenzahl = $this->ReadAttributeInteger('ZustandPhasenzahl');
$strom = $leistung === 0
? 0
: LadestationRegler::stromFuerLeistung($leistung, $phasenzahl);
if ($strom === $this->ReadAttributeInteger('LetzterGesetzterStrom')) {
return;
}
$transport = new LadestationTransport(
fn (array $anfrage): string => $this->fuehreHttpAnfrageAus($anfrage)
);
$transport->ladestromSetzen(
$this->ReadPropertyInteger('Geraetetyp'),
$this->geraetekonfiguration(),
$strom
);
$this->WriteAttributeInteger('LetzterGesetzterStrom', $strom);
}
/**
* @param array<string, mixed> $anfrage
*/
private function fuehreHttpAnfrageAus(array $anfrage): string
{
$protokoll = [
'Methode' => $anfrage['Methode'],
'URL' => $anfrage['URL'],
];
$this->setzeSichtbareVariable(
'LetzterGeraetebefehl',
json_encode($protokoll, JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR)
);
if ($this->ReadPropertyBoolean('Testmodus')) {
$url = (string) $anfrage['URL'];
if (str_ends_with($url, '/api/status')
|| str_ends_with($url, '/mqtt?payload=')
|| str_contains($url, '/pico/charging/')
) {
return $this->ReadPropertyString('Testantwort');
}
return '{}';
}
$curl = curl_init();
if ($curl === false) {
throw new RuntimeException('HTTP-Anfrage konnte nicht initialisiert werden.');
}
curl_setopt($curl, CURLOPT_URL, (string) $anfrage['URL']);
curl_setopt($curl, CURLOPT_RETURNTRANSFER, true);
curl_setopt($curl, CURLOPT_CONNECTTIMEOUT, 5);
curl_setopt($curl, CURLOPT_TIMEOUT, 10);
if ($anfrage['Methode'] === 'POST') {
curl_setopt($curl, CURLOPT_POST, true);
}
if (isset($anfrage['Benutzername'], $anfrage['Passwort'])) {
curl_setopt($curl, CURLOPT_HTTPAUTH, CURLAUTH_BASIC);
curl_setopt(
$curl,
CURLOPT_USERPWD,
(string) $anfrage['Benutzername'] . ':' . (string) $anfrage['Passwort']
);
}
$antwort = curl_exec($curl);
if ($antwort === false) {
$fehler = curl_error($curl);
curl_close($curl);
throw new RuntimeException('HTTP-Anfrage fehlgeschlagen: ' . $fehler);
}
$status = (int) curl_getinfo($curl, CURLINFO_HTTP_CODE);
curl_close($curl);
if ($status >= 400) {
throw new RuntimeException('Geraet antwortet mit HTTP-Status ' . $status . '.');
}
return (string) $antwort;
}
/** @return array<string, string> */
private function geraetekonfiguration(): array
{
return [
'Geraeteadresse' => $this->ReadPropertyString('Geraeteadresse'),
'GeraeteID' => $this->ReadPropertyString('GeraeteID'),
'Seriennummer' => $this->ReadPropertyString('Seriennummer'),
'Benutzername' => $this->ReadPropertyString('Benutzername'),
'Passwort' => $this->ReadPropertyString('Passwort'),
];
}
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
{
$stoerung = (bool) $this->leseZustand('Stoerung');
return [
'Kopf' => [
'Version' => Nachrichtenvertrag::VERSION,
'AbsenderID' => $this->InstanceID,
'EmpfaengerID' => $managerID,
'Zeitpunkt' => time(),
],
'Betriebsart' => $this->ReadAttributeString('Betriebsart'),
'PrioritaetPV' => $this->ReadPropertyInteger('PrioritaetPV'),
'PrioritaetPeak' => $this->ReadPropertyInteger('PrioritaetPeak'),
'Leistungswerte_W' => $this->leseLeistungsangebot(),
'AenderungMoeglich' => (bool) $this->leseZustand('AenderungMoeglich'),
'Verfuegbar' => (bool) $this->leseZustand('Verfuegbar'),
'Istleistung_W' => (float) $this->leseZustand('Istleistung'),
'Leistungsquelle' => Nachrichtenvertrag::LEISTUNGSQUELLE_GEMESSEN,
'Zustand' => [
[
'Kennung' => 'Sollleistung_W',
'Art' => 'Sollwert',
'Wert' => (bool) $this->leseZustand('SollwertGueltig')
? (int) $this->leseZustand('Sollleistung')
: null,
'Einheit' => 'W',
],
[
'Kennung' => 'FahrzeugVerbunden',
'Art' => 'Status',
'Wert' => (bool) $this->leseZustand('FahrzeugVerbunden'),
'Einheit' => '',
],
[
'Kennung' => 'FahrzeugGeladen',
'Art' => 'Status',
'Wert' => (bool) $this->leseZustand('FahrzeugGeladen'),
'Einheit' => '',
],
[
'Kennung' => 'Fahrzeugstatus',
'Art' => 'Status',
'Wert' => (int) $this->leseZustand('Fahrzeugstatus'),
'Einheit' => '',
],
[
'Kennung' => 'Phasenzahl',
'Art' => 'Status',
'Wert' => (int) $this->leseZustand('Phasenzahl'),
'Einheit' => '',
],
[
'Kennung' => 'Ladestrom_A',
'Art' => 'Istwert',
'Wert' => (float) $this->leseZustand('Ladestrom'),
'Einheit' => 'A',
],
[
'Kennung' => 'Ladefreigabe',
'Art' => 'Status',
'Wert' => $this->ReadAttributeBoolean('ZustandLadefreigabe'),
'Einheit' => '',
],
[
'Kennung' => 'Solarladen',
'Art' => 'Status',
'Wert' => $this->ReadAttributeBoolean('ZustandSolarladen'),
'Einheit' => '',
],
[
'Kennung' => 'Ladefehler',
'Art' => 'Stoerung',
'Wert' => $stoerung,
'Einheit' => '',
'Text' => (string) $this->leseZustand('Stoertext'),
],
],
];
}
/** @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', 'Abfrageintervall'] as $property) {
if ($this->ReadPropertyInteger($property) <= 0) {
throw new InvalidArgumentException($property . ' muss groesser als 0 sein.');
}
}
$maximalstrom = $this->ReadPropertyInteger('MaximalerLadestrom');
if ($maximalstrom < 6 || $maximalstrom > 32) {
throw new InvalidArgumentException('MaximalerLadestrom muss zwischen 6 und 32 A liegen.');
}
$geraetetyp = $this->ReadPropertyInteger('Geraetetyp');
$konfiguration = $this->geraetekonfiguration();
LadestationAdapter::statusAnfrage($geraetetyp, $konfiguration);
LadestationAdapter::steuerAnfragen($geraetetyp, $konfiguration, 0);
}
private function initialisiereLokaleEinstellungen(): void
{
$initialisiert = $this->ReadAttributeBoolean('InitialwerteGesetzt');
$ladefreigabe = $this->ReadPropertyBoolean('Ladefreigabe');
$solarladen = $this->ReadPropertyBoolean('Solarladen');
if (!$initialisiert
|| $ladefreigabe !== $this->ReadAttributeBoolean('KonfigurationLadefreigabe')
) {
$this->WriteAttributeBoolean('ZustandLadefreigabe', $ladefreigabe);
}
if (!$initialisiert
|| $solarladen !== $this->ReadAttributeBoolean('KonfigurationSolarladen')
) {
$this->WriteAttributeBoolean('ZustandSolarladen', $solarladen);
}
$this->WriteAttributeBoolean('KonfigurationLadefreigabe', $ladefreigabe);
$this->WriteAttributeBoolean('KonfigurationSolarladen', $solarladen);
$this->WriteAttributeBoolean('InitialwerteGesetzt', true);
}
private function aktualisiereVariablen(): void
{
if ($this->ReadPropertyBoolean('EinstellungenInVisu')) {
$this->RegisterVariableBoolean('Ladefreigabe', 'Ladefreigabe', '~Switch', 150);
$this->RegisterVariableBoolean('Solarladen', 'Solarladen', '~Switch', 160);
$this->EnableAction('Ladefreigabe');
$this->EnableAction('Solarladen');
$this->SetValue('Ladefreigabe', $this->ReadAttributeBoolean('ZustandLadefreigabe'));
$this->SetValue('Solarladen', $this->ReadAttributeBoolean('ZustandSolarladen'));
} else {
$this->entferneVariable('Ladefreigabe');
$this->entferneVariable('Solarladen');
}
if ($this->ReadPropertyBoolean('DiagnosevariablenAnzeigen')) {
$this->registriereVerbraucherDiagnose();
$this->RegisterVariableString('LetzterGeraetebefehl', 'Letzter Geraetebefehl', '', 200);
$this->RegisterVariableString('LeistungsangebotDiagnose', 'Leistungsangebot', '', 210);
$this->SetValue(
'LeistungsangebotDiagnose',
$this->ReadAttributeString('Leistungsangebot')
);
foreach (self::DIAGNOSE_VARIABLEN as $ident) {
if (!in_array($ident, ['LetzterGeraetebefehl', 'LeistungsangebotDiagnose'], true)) {
$this->setzeSichtbareVariable($ident, $this->leseZustand($ident));
}
}
} else {
foreach (self::DIAGNOSE_VARIABLEN as $ident) {
$this->entferneVariable($ident);
}
}
}
private function entferneVariable(string $ident): void
{
$id = @$this->GetIDForIdent($ident);
if (is_int($id) && $id > 0 && IPS_VariableExists($id)) {
$this->UnregisterVariable($ident);
}
}
/** @param mixed $wert */
private function setzeZustand(string $ident, $wert): void
{
switch ($ident) {
case 'FahrzeugVerbunden':
case 'FahrzeugGeladen':
case 'SollwertGueltig':
case 'Verfuegbar':
case 'AenderungMoeglich':
case 'Stoerung':
$this->WriteAttributeBoolean('Zustand' . $ident, (bool) $wert);
break;
case 'Fahrzeugstatus':
case 'Phasenzahl':
case 'Sollleistung':
$this->WriteAttributeInteger('Zustand' . $ident, (int) $wert);
break;
case 'Ladestrom_A':
$ident = 'Ladestrom';
$this->WriteAttributeFloat('ZustandLadestrom', (float) $wert);
break;
case 'Istleistung_W':
$ident = 'Istleistung';
$this->WriteAttributeFloat('ZustandIstleistung', (float) $wert);
break;
case 'Stoertext':
$this->WriteAttributeString('ZustandStoertext', (string) $wert);
break;
default:
throw new LogicException('Unbekannter Zustand: ' . $ident);
}
$this->setzeSichtbareVariable($ident, $wert);
if ($ident === 'Istleistung') {
$this->setzeSichtbareVariable(
'Leistungsquelle',
Nachrichtenvertrag::LEISTUNGSQUELLE_GEMESSEN
);
}
}
/** @return mixed */
private function leseZustand(string $ident)
{
if ($ident === 'Ladefreigabe' || $ident === 'Solarladen') {
return $this->ReadAttributeBoolean('Zustand' . $ident);
}
if (in_array($ident, [
'FahrzeugVerbunden', 'FahrzeugGeladen', 'SollwertGueltig',
'Verfuegbar', 'AenderungMoeglich', 'Stoerung',
], true)) {
return $this->ReadAttributeBoolean('Zustand' . $ident);
}
if (in_array($ident, ['Fahrzeugstatus', 'Phasenzahl', 'Sollleistung'], true)) {
return $this->ReadAttributeInteger('Zustand' . $ident);
}
if ($ident === 'Ladestrom' || $ident === 'Istleistung') {
return $this->ReadAttributeFloat('Zustand' . $ident);
}
if ($ident === 'Stoertext') {
return $this->ReadAttributeString('ZustandStoertext');
}
if ($ident === 'Leistungsquelle') {
return Nachrichtenvertrag::LEISTUNGSQUELLE_GEMESSEN;
}
throw new LogicException('Unbekannter Zustand: ' . $ident);
}
/** @param mixed $wert */
private function setzeSichtbareVariable(string $ident, $wert): void
{
$id = @$this->GetIDForIdent($ident);
if (is_int($id) && $id > 0 && IPS_VariableExists($id)) {
$this->SetValue($ident, $wert);
}
}
/** @return list<int> */
private function leseLeistungsangebot(): array
{
$angebot = json_decode(
$this->ReadAttributeString('Leistungsangebot'),
true,
512,
JSON_THROW_ON_ERROR
);
return is_array($angebot) ? array_map('intval', $angebot) : [0];
}
private function setzeStoerung(string $text): void
{
$this->setzeZustand('Stoerung', $text !== '');
$this->setzeZustand('Stoertext', $text);
}
private function deaktiviereTimer(): void
{
foreach (['Statuszyklus', 'Meldezyklus', 'RueckmeldungVerzoegert', 'VorgabeTimeout'] as $timer) {
$this->SetTimerInterval($timer, 0);
}
}
private function protokolliere(string $bereich, string $nachricht): void
{
if (!$this->ReadPropertyBoolean('LoggingEin')) {
return;
}
$this->SendDebug($bereich, $nachricht, 0);
}
}
+301
View File
@@ -0,0 +1,301 @@
{
"elements": [
{
"type": "ExpansionPanel",
"caption": "Lizenzierung",
"expanded": true,
"items": [
{
"type": "ValidationTextBox",
"name": "Lizenzcode",
"caption": "Lizenzcode",
"placeholder": "ENX-XXXX-XXXX-XXXX-XXXX"
},
{
"type": "Label",
"name": "LizenzInstallationID",
"caption": "Installations-ID wird erzeugt"
},
{
"type": "Label",
"name": "LizenzInformation",
"caption": "Lizenzcode fehlt."
},
{
"type": "Button",
"name": "LizenzPruefen",
"caption": "Lizenz pruefen und binden",
"onClick": "IPS_RequestAction($id, 'FormLizenzPruefen', $Lizenzcode);"
}
]
},
{
"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"},
{"code": 203, "icon": "error", "caption": "Lizenz fehlt oder ist abgelaufen"}
]
}
+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"
}
+1078
View File
File diff suppressed because it is too large Load Diff
+163
View File
@@ -0,0 +1,163 @@
{
"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": "LastwechselSperrzeit", "caption": "Lastwechsel-Sperrzeit", "suffix": " s", "minimum": 0}
]
},
{
"type": "ExpansionPanel",
"caption": "Pufferspeicher",
"expanded": true,
"items": [
{
"type": "List",
"name": "LeistungsStufen",
"caption": "Leistungsstufen",
"add": true,
"delete": 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": "300px",
"add": 0,
"edit": {"type": "SelectVariable", "validVariableTypes": [0]}
}
]
},
{
"type": "SelectVariable",
"name": "PufferfuehlerVariableID",
"caption": "Puffertemperatur",
"validVariableTypes": [1, 2]
},
{
"type": "SelectVariable",
"name": "AussentemperaturVariableID",
"caption": "Aussentemperatur",
"validVariableTypes": [1, 2]
}
]
},
{
"type": "ExpansionPanel",
"caption": "Heizkurve",
"expanded": true,
"items": [
{
"type": "NumberSpinner",
"name": "FusspunktVorlauftemperatur",
"caption": "Vorlauftemperatur bei 20 Grad C aussen",
"suffix": " Grad C",
"digits": 1
},
{
"type": "NumberSpinner",
"name": "HeizkurvenSteigung",
"caption": "Heizkurvensteigung",
"minimum": 0,
"digits": 2
},
{
"type": "NumberSpinner",
"name": "HeizkurveMinimaltemperatur",
"caption": "Untere Begrenzung der Solltemperatur",
"suffix": " Grad C",
"digits": 1
},
{
"type": "NumberSpinner",
"name": "HeizkurveMaximaltemperatur",
"caption": "Obere Begrenzung der Solltemperatur",
"suffix": " Grad C",
"digits": 1
},
{
"type": "NumberSpinner",
"name": "Hysterese",
"caption": "Hysterese",
"suffix": " K",
"minimum": 0.1,
"digits": 1
},
{
"type": "Select",
"name": "MindesttemperaturModus",
"caption": "Optionale Mindesttemperatur",
"options": [
{"caption": "Aus", "value": 0},
{"caption": "Statisch", "value": 1},
{"caption": "Differenz zur Solltemperatur", "value": 2}
]
},
{
"type": "NumberSpinner",
"name": "Mindesttemperatur",
"caption": "Statische Mindesttemperatur",
"suffix": " Grad C",
"digits": 1
},
{
"type": "NumberSpinner",
"name": "MindesttemperaturDifferenz",
"caption": "Differenz zur Solltemperatur",
"suffix": " K",
"minimum": 0,
"digits": 1
},
{
"type": "SelectVariable",
"name": "WaermepumpenSolltemperaturVariableID",
"caption": "Optionale Solltemperatur der Waermepumpe",
"validVariableTypes": [1, 2]
},
{
"type": "Button",
"name": "HeizkurveVonWaermepumpeUebernehmen",
"caption": "Wert von Waermepumpe uebernehmen",
"onClick": "IPS_RequestAction($id, 'HeizkurveVonWaermepumpeUebernehmen', 0);"
}
]
},
{
"type": "ExpansionPanel",
"caption": "Erweiterte Einstellungen",
"items": [
{"type": "NumberSpinner", "name": "TemperaturMaxAlter", "caption": "Maximales Temperaturalter", "suffix": " s", "minimum": 1},
{"type": "CheckBox", "name": "PuffertemperaturGlaetten", "caption": "Puffertemperatur glaetten"},
{"type": "NumberSpinner", "name": "ZeitKonstante", "caption": "PT1-Zeitkonstante", "suffix": " s", "minimum": 1},
{"type": "CheckBox", "name": "EinstellungenInVisu", "caption": "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": "Temperaturmessung ungueltig"},
{"code": 202, "icon": "error", "caption": "Konfiguration ungueltig"},
{"code": 203, "icon": "error", "caption": "Schaltfehler"}
]
}
+14
View File
@@ -0,0 +1,14 @@
{
"id": "{C92D5EEF-9632-47A5-9659-4B02BF40FBE9}",
"name": "VerbraucherPufferspeicher",
"type": 3,
"vendor": "Enelix",
"aliases": [
"Pufferspeicher"
],
"parentRequirements": [],
"childRequirements": [],
"implemented": [],
"prefix": "ENELIX",
"url": "https://git.belevo.ch/ENELIX/Enelix-EMS/src/branch/develop/Pufferspeicher"
}
+899
View File
@@ -0,0 +1,899 @@
<?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/PufferspeicherRegler.php';
use Belevo\EnelixEMS\Nachrichtenvertrag;
use Belevo\EnelixEMS\PufferspeicherRegler;
use Belevo\EnelixEMS\VerbraucherBasisTrait;
use Belevo\EnelixEMS\VerbraucherSchnittstelle;
class VerbraucherPufferspeicher extends IPSModule implements VerbraucherSchnittstelle
{
use VerbraucherBasisTrait;
private const MANAGER_MODULE_ID = '{6F771B18-59D4-4C8A-B951-3B2FE9F6A2C4}';
private const STATUS_AKTIV = 102;
private const STATUS_TEMPERATUR_UNGUELTIG = 201;
private const STATUS_KONFIGURATION_UNGUELTIG = 202;
private const STATUS_SCHALTFEHLER = 203;
private const VM_UPDATE = 10603;
/** @var list<string> */
private const DIAGNOSE_VARIABLEN = [
'Istleistung',
'Leistungsquelle',
'Sollleistung',
'SollwertGueltig',
'Verfuegbar',
'AenderungMoeglich',
'Stoerung',
'Stoertext',
'AktiveStufe',
'BezogeneEnergie',
'TemperaturenGueltig',
];
public function Create(): void
{
parent::Create();
$this->registriereVerbraucherBasis();
$this->RegisterPropertyString('LeistungsStufen', '[]');
$this->RegisterPropertyInteger('PufferfuehlerVariableID', 0);
$this->RegisterPropertyInteger('AussentemperaturVariableID', 0);
$this->RegisterPropertyInteger('WaermepumpenSolltemperaturVariableID', 0);
$this->RegisterPropertyFloat('FusspunktVorlauftemperatur', 35.0);
$this->RegisterPropertyFloat('HeizkurvenSteigung', 1.0);
$this->RegisterPropertyFloat('HeizkurveMinimaltemperatur', 20.0);
$this->RegisterPropertyFloat('HeizkurveMaximaltemperatur', 80.0);
$this->RegisterPropertyFloat('Hysterese', 5.0);
$this->RegisterPropertyInteger('MindesttemperaturModus', PufferspeicherRegler::MINIMUM_AUS);
$this->RegisterPropertyFloat('Mindesttemperatur', 20.0);
$this->RegisterPropertyFloat('MindesttemperaturDifferenz', 5.0);
$this->RegisterPropertyInteger('LastwechselSperrzeit', 5);
$this->RegisterPropertyInteger('TemperaturMaxAlter', 120);
$this->RegisterPropertyBoolean('PuffertemperaturGlaetten', false);
$this->RegisterPropertyInteger('ZeitKonstante', 120);
$this->RegisterPropertyBoolean('DiagnosevariablenAnzeigen', false);
$this->RegisterVariableFloat('Puffertemperatur', 'Puffertemperatur', '~Temperature', 100);
$this->RegisterVariableFloat('Aussentemperatur', 'Aussentemperatur', '~Temperature', 110);
$this->RegisterVariableFloat('Solltemperatur', 'Solltemperatur', '~Temperature', 120);
$this->RegisterVariableBoolean('Heizbedarf', 'Heizbedarf', '~Switch', 130);
$this->RegisterAttributeInteger('RegistriertePufferID', 0);
$this->RegisterAttributeInteger('RegistrierteAussenID', 0);
$this->RegisterAttributeInteger('LetzteBerechnung', 0);
$this->RegisterAttributeInteger('LetzteTemperaturberechnung', 0);
$this->RegisterAttributeInteger('LetzteVorgabeZeit', 0);
$this->RegisterAttributeInteger('LetzterLastwechsel', 0);
$this->RegisterAttributeInteger('LetzteManagerID', 0);
$this->RegisterAttributeFloat('Glaettungswert', 0.0);
$this->RegisterAttributeBoolean('GlaettungInitialisiert', false);
$this->RegisterAttributeString('Leistungsangebot', '[0]');
$this->RegisterAttributeString('Betriebsart', Nachrichtenvertrag::BETRIEBSART_PV);
$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->RegisterAttributeInteger('ZustandAktiveStufe', 0);
$this->RegisterAttributeFloat('ZustandBezogeneEnergie', 0.0);
$this->RegisterAttributeBoolean('ZustandTemperaturenGueltig', false);
$this->RegisterTimer(
'LastwechselFreigabe',
0,
"IPS_RequestAction(\$_IPS['TARGET'], 'LastwechselFreigabe', false);"
);
$this->RegisterTimer(
'Meldezyklus',
0,
"IPS_RequestAction(\$_IPS['TARGET'], 'Melden', false);"
);
$this->RegisterTimer(
'RueckmeldungVerzoegert',
0,
"IPS_RequestAction(\$_IPS['TARGET'], 'Melden', true);"
);
}
public function ApplyChanges(): void
{
parent::ApplyChanges();
$this->aktualisiereVariablen();
try {
$this->pruefeKonfiguration();
} catch (Throwable $fehler) {
$this->deaktiviereTimer();
$this->setzeZustand('Verfuegbar', false);
$this->setzeZustand('AenderungMoeglich', false);
$this->setzeZustand('Stoerung', true);
$this->setzeZustand('Stoertext', $fehler->getMessage());
$this->SetStatus(self::STATUS_KONFIGURATION_UNGUELTIG);
$this->protokolliere('Konfiguration', $fehler->getMessage());
return;
}
$this->registriereTemperaturmeldungen();
$this->SetTimerInterval('LastwechselFreigabe', 0);
$this->SetTimerInterval('Meldezyklus', $this->ReadPropertyInteger('Meldeintervall') * 1000);
$this->regelzyklus(true);
}
public function MessageSink($zeitstempel, $senderID, $nachricht, $daten): void
{
if ((int) $nachricht !== self::VM_UPDATE) {
return;
}
if (in_array((int) $senderID, [
$this->ReadPropertyInteger('PufferfuehlerVariableID'),
$this->ReadPropertyInteger('AussentemperaturVariableID'),
], true)) {
$this->regelzyklus(true);
}
}
public function RequestAction($ident, $wert): void
{
switch ($ident) {
case 'Aktiv':
$this->SetValue('Aktiv', (bool) $wert);
if (!(bool) $wert) {
$this->setzeZustand('SollwertGueltig', false);
}
$this->regelzyklus(true);
return;
case 'HeizkurveVonWaermepumpeUebernehmen':
$this->uebernehmeWaermepumpenwert();
return;
case 'LastwechselFreigabe':
$this->SetTimerInterval('LastwechselFreigabe', 0);
$this->regelzyklus(true);
return;
case 'Melden':
if ((bool) $wert) {
$this->SetTimerInterval('RueckmeldungVerzoegert', 0);
}
$this->regelzyklus(false);
$this->sendeVerbraucherdaten();
return;
case 'ManagerdatenEmpfangen':
if (!is_string($wert)) {
throw new InvalidArgumentException('Managerdaten muessen als JSON uebergeben werden.');
}
$daten = json_decode($wert, true, 512, JSON_THROW_ON_ERROR);
if (!is_array($daten)) {
throw new InvalidArgumentException('Managerdaten muessen ein JSON-Objekt sein.');
}
$this->ManagerdatenEmpfangen($daten);
return;
}
throw new InvalidArgumentException('Unbekannte Aktion: ' . $ident);
}
/** @param array<string, mixed> $daten */
public function ManagerdatenEmpfangen(array $daten): void
{
Nachrichtenvertrag::pruefeManagerdaten($daten);
if ($daten['Kopf']['EmpfaengerID'] !== $this->InstanceID) {
throw new InvalidArgumentException('Managerdaten sind an eine andere Instanz adressiert.');
}
$managerID = $daten['Kopf']['AbsenderID'];
if (!in_array($managerID, $this->zugeordneteManagerIDs(), true)) {
throw new InvalidArgumentException('Der Manager hat diesen Pufferspeicher nicht aktiv zugeordnet.');
}
$this->WriteAttributeString('Betriebsart', $daten['Betriebsart']);
$this->regelzyklus(false);
$angebot = $this->leseLeistungsangebot();
$sollleistung = $daten['Sollleistung_W'];
if ($sollleistung === null) {
if ((bool) $this->leseZustand('SollwertGueltig')
&& !in_array((int) $this->leseZustand('Sollleistung'), $angebot, true)
) {
$this->setzeZustand('SollwertGueltig', false);
$this->regelzyklus(false);
}
$this->SetTimerInterval('RueckmeldungVerzoegert', 100);
return;
}
if (!in_array($sollleistung, $angebot, true)) {
throw new InvalidArgumentException('Sollleistung_W liegt nicht im aktuell gemeldeten Leistungsangebot.');
}
$this->WriteAttributeInteger('LetzteManagerID', $managerID);
$this->WriteAttributeInteger('LetzteVorgabeZeit', time());
$this->setzeZustand('Sollleistung', $sollleistung);
$this->setzeZustand('SollwertGueltig', true);
$this->regelzyklus(false);
$this->SetTimerInterval('RueckmeldungVerzoegert', 100);
}
private function regelzyklus(bool $meldungPlanen): void
{
try {
$jetzt = time();
$this->aktualisiereEnergie($jetzt);
$temperaturenGueltig = $this->aktualisiereTemperaturen();
$stufen = PufferspeicherRegler::dekodiereLeistungsstufen(
$this->ReadPropertyString('LeistungsStufen')
);
$solltemperatur = 0.0;
$einschaltschwelle = 0.0;
if ($temperaturenGueltig) {
$solltemperatur = PufferspeicherRegler::solltemperatur(
(float) $this->GetValue('Aussentemperatur'),
$this->ReadPropertyFloat('FusspunktVorlauftemperatur'),
$this->ReadPropertyFloat('HeizkurvenSteigung'),
$this->ReadPropertyFloat('HeizkurveMinimaltemperatur'),
$this->ReadPropertyFloat('HeizkurveMaximaltemperatur')
);
$einschaltschwelle = PufferspeicherRegler::einschaltschwelle(
$solltemperatur,
$this->ReadPropertyFloat('Hysterese'),
$this->ReadPropertyInteger('MindesttemperaturModus'),
$this->ReadPropertyFloat('Mindesttemperatur'),
$this->ReadPropertyFloat('MindesttemperaturDifferenz')
);
}
$this->SetValue('Solltemperatur', $solltemperatur);
$aktiv = (bool) $this->GetValue('Aktiv');
$peak = $this->ReadAttributeString('Betriebsart') === Nachrichtenvertrag::BETRIEBSART_PEAK;
$angebot = PufferspeicherRegler::leistungsangebot(
$aktiv,
$peak,
$temperaturenGueltig,
(float) $this->GetValue('Puffertemperatur'),
$einschaltschwelle,
$stufen
);
$heizbedarf = count($angebot) > 1;
$this->SetValue('Heizbedarf', $heizbedarf);
$verfuegbar = $aktiv && $temperaturenGueltig && $stufen !== [];
$aenderungMoeglich = $verfuegbar && count($angebot) > 1;
$zwangsleistung = (!$verfuegbar || !$heizbedarf) ? 0 : null;
$aktuelleLeistung = (int) round((float) $this->leseZustand('Istleistung'));
$restzeit = PufferspeicherRegler::restzeitBisLastwechsel(
$this->ReadAttributeInteger('LetzterLastwechsel'),
$jetzt,
$this->ReadPropertyInteger('LastwechselSperrzeit')
);
$lastwechselGesperrt = $restzeit > 0 && $verfuegbar && $zwangsleistung !== 0;
$this->SetTimerInterval('LastwechselFreigabe', $lastwechselGesperrt ? $restzeit * 1000 : 0);
if ($lastwechselGesperrt) {
$angebot = [$aktuelleLeistung];
$aenderungMoeglich = false;
}
$letzteVorgabe = $this->ReadAttributeInteger('LetzteVorgabeZeit');
if ($letzteVorgabe <= 0
|| $jetzt - $letzteVorgabe > $this->ReadPropertyInteger('VorgabeTimeout')
) {
$this->setzeZustand('SollwertGueltig', false);
}
$ziel = 0;
if ($lastwechselGesperrt) {
$ziel = $aktuelleLeistung;
} elseif ($zwangsleistung !== null) {
$ziel = $zwangsleistung;
} elseif ((bool) $this->leseZustand('SollwertGueltig')) {
$ziel = (int) $this->leseZustand('Sollleistung');
if (!in_array($ziel, $angebot, true)) {
$this->setzeZustand('SollwertGueltig', false);
$ziel = 0;
}
}
$lastwechsel = $this->schalteLeistung($ziel, $jetzt);
if ($lastwechsel) {
$angebot = [$ziel];
$aenderungMoeglich = false;
}
$this->WriteAttributeString('Leistungsangebot', json_encode($angebot, JSON_THROW_ON_ERROR));
$this->setzeZustand('Sollleistung', $ziel);
$this->setzeZustand('Verfuegbar', $verfuegbar);
$this->setzeZustand('AenderungMoeglich', $aenderungMoeglich);
$this->aktualisiereStoerungen($temperaturenGueltig);
if ($meldungPlanen) {
$this->SetTimerInterval('RueckmeldungVerzoegert', 100);
}
} catch (Throwable $fehler) {
$this->WriteAttributeString('Schaltfehler', $fehler->getMessage());
$this->setzeZustand('Verfuegbar', false);
$this->setzeZustand('AenderungMoeglich', false);
$this->setzeZustand('Stoerung', true);
$this->setzeZustand('Stoertext', $fehler->getMessage());
$this->SetStatus(self::STATUS_SCHALTFEHLER);
$this->protokolliere('Regelzyklus', $fehler->getMessage());
}
}
private function aktualisiereTemperaturen(): bool
{
$pufferID = $this->ReadPropertyInteger('PufferfuehlerVariableID');
$aussenID = $this->ReadPropertyInteger('AussentemperaturVariableID');
if (!$this->temperaturwertGueltig($pufferID) || !$this->temperaturwertGueltig($aussenID)) {
$this->setzeZustand('TemperaturenGueltig', false);
return false;
}
$puffertemperatur = (float) GetValue($pufferID);
$aussentemperatur = (float) GetValue($aussenID);
$jetzt = time();
$letzteBerechnung = $this->ReadAttributeInteger('LetzteTemperaturberechnung');
$this->WriteAttributeInteger('LetzteTemperaturberechnung', $jetzt);
if ($this->ReadPropertyBoolean('PuffertemperaturGlaetten')) {
if (!$this->ReadAttributeBoolean('GlaettungInitialisiert')) {
$this->WriteAttributeFloat('Glaettungswert', $puffertemperatur);
$this->WriteAttributeBoolean('GlaettungInitialisiert', true);
} elseif ($letzteBerechnung > 0 && $jetzt > $letzteBerechnung) {
$puffertemperatur = PufferspeicherRegler::pt1(
$this->ReadAttributeFloat('Glaettungswert'),
$puffertemperatur,
$jetzt - $letzteBerechnung,
$this->ReadPropertyInteger('ZeitKonstante')
);
$this->WriteAttributeFloat('Glaettungswert', $puffertemperatur);
} else {
$puffertemperatur = $this->ReadAttributeFloat('Glaettungswert');
}
} else {
$this->WriteAttributeBoolean('GlaettungInitialisiert', false);
}
$this->SetValue('Puffertemperatur', $puffertemperatur);
$this->SetValue('Aussentemperatur', $aussentemperatur);
$this->setzeZustand('TemperaturenGueltig', true);
return true;
}
private function temperaturwertGueltig(int $variablenID): bool
{
if ($variablenID <= 0 || !IPS_VariableExists($variablenID)) {
return false;
}
$variable = IPS_GetVariable($variablenID);
if (!in_array((int) $variable['VariableType'], [1, 2], true)) {
return false;
}
if (time() - (int) $variable['VariableUpdated'] > $this->ReadPropertyInteger('TemperaturMaxAlter')) {
return false;
}
$wert = GetValue($variablenID);
return is_int($wert) || is_float($wert);
}
private function uebernehmeWaermepumpenwert(): void
{
$wpID = $this->ReadPropertyInteger('WaermepumpenSolltemperaturVariableID');
$aussenID = $this->ReadPropertyInteger('AussentemperaturVariableID');
if (!$this->temperaturwertGueltig($wpID) || !$this->temperaturwertGueltig($aussenID)) {
throw new RuntimeException('Waermepumpen-Solltemperatur oder Aussentemperatur ist ungueltig.');
}
$fusspunkt = PufferspeicherRegler::fusspunktAusWaermepumpenwert(
(float) GetValue($wpID),
(float) GetValue($aussenID),
$this->ReadPropertyFloat('HeizkurvenSteigung')
);
IPS_SetProperty($this->InstanceID, 'FusspunktVorlauftemperatur', $fusspunkt);
IPS_ApplyChanges($this->InstanceID);
$this->protokolliere('Heizkurve', ['FusspunktVorlauftemperatur' => $fusspunkt]);
}
private function schalteLeistung(int $zielLeistung, int $jetzt): bool
{
$stufen = PufferspeicherRegler::dekodiereLeistungsstufen(
$this->ReadPropertyString('LeistungsStufen')
);
$zielStufe = PufferspeicherRegler::stufeFuerLeistung($stufen, $zielLeistung);
$bisher = (int) round((float) $this->leseZustand('Istleistung'));
if ($zielLeistung === $bisher && $this->kontakteEntsprechenZiel($stufen, $zielLeistung)) {
$this->WriteAttributeString('Schaltfehler', '');
return false;
}
try {
foreach ($stufen as $stufe) {
RequestAction($stufe['KontaktID'], false);
}
foreach ($stufen as $stufe) {
if ($stufe['Leistung_W'] === $zielLeistung && $zielLeistung > 0) {
RequestAction($stufe['KontaktID'], true);
break;
}
}
} catch (Throwable $fehler) {
foreach ($stufen as $stufe) {
try {
RequestAction($stufe['KontaktID'], false);
} catch (Throwable $ignoriert) {
}
}
$this->setzeZustand('Istleistung', 0.0);
$this->setzeZustand('AktiveStufe', 0);
throw new RuntimeException('Leistungsstufe konnte nicht sicher geschaltet werden: ' . $fehler->getMessage());
}
$this->WriteAttributeString('Schaltfehler', '');
$this->setzeZustand('Istleistung', (float) $zielLeistung);
$this->setzeZustand('Leistungsquelle', Nachrichtenvertrag::LEISTUNGSQUELLE_BERECHNET);
$this->setzeZustand('AktiveStufe', $zielStufe);
$this->WriteAttributeInteger('LetzterLastwechsel', $jetzt);
$this->protokolliere('Leistungsstufe', ['Leistung_W' => $zielLeistung, 'Stufe' => $zielStufe]);
return true;
}
/** @param list<array{Stufe: int, Leistung_W: int, KontaktID: int}> $stufen */
private function kontakteEntsprechenZiel(array $stufen, int $zielLeistung): bool
{
foreach ($stufen as $stufe) {
if (!IPS_VariableExists($stufe['KontaktID'])) {
return false;
}
$sollEin = $zielLeistung > 0 && $stufe['Leistung_W'] === $zielLeistung;
if ((bool) GetValue($stufe['KontaktID']) !== $sollEin) {
return false;
}
}
return true;
}
private function aktualisiereEnergie(int $jetzt): void
{
$letzteBerechnung = $this->ReadAttributeInteger('LetzteBerechnung');
$this->WriteAttributeInteger('LetzteBerechnung', $jetzt);
if ($letzteBerechnung <= 0 || $jetzt <= $letzteBerechnung) {
return;
}
$zuwachs = PufferspeicherRegler::energieKWh(
(float) $this->leseZustand('Istleistung'),
$jetzt - $letzteBerechnung
);
$this->setzeZustand(
'BezogeneEnergie',
(float) $this->leseZustand('BezogeneEnergie') + $zuwachs
);
}
private function aktualisiereStoerungen(bool $temperaturenGueltig): void
{
$stoerungen = [];
if (!$temperaturenGueltig) {
$stoerungen[] = 'Puffer- oder Aussentemperatur fehlt oder ist veraltet.';
}
if ($this->ReadAttributeString('Schaltfehler') !== '') {
$stoerungen[] = $this->ReadAttributeString('Schaltfehler');
}
$this->setzeZustand('Stoerung', $stoerungen !== []);
$this->setzeZustand('Stoertext', implode("
", array_values(array_unique($stoerungen))));
if ($this->ReadAttributeString('Schaltfehler') !== '') {
$this->SetStatus(self::STATUS_SCHALTFEHLER);
} elseif (!$temperaturenGueltig) {
$this->SetStatus(self::STATUS_TEMPERATUR_UNGUELTIG);
} else {
$this->SetStatus(self::STATUS_AKTIV);
}
}
private function sendeVerbraucherdaten(): void
{
foreach ($this->zugeordneteManagerIDs() as $managerID) {
$daten = $this->baueVerbraucherdaten($managerID);
Nachrichtenvertrag::pruefeVerbraucherdaten($daten);
try {
IPS_RequestAction(
$managerID,
'VerbraucherdatenEmpfangen',
json_encode($daten, JSON_THROW_ON_ERROR)
);
} catch (Throwable $fehler) {
$this->protokolliere('Managerkommunikation', $fehler->getMessage());
}
}
}
/** @return array<string, mixed> */
private function baueVerbraucherdaten(int $managerID): array
{
$temperaturenGueltig = (bool) $this->leseZustand('TemperaturenGueltig');
$schaltfehler = $this->ReadAttributeString('Schaltfehler');
return [
'Kopf' => [
'Version' => Nachrichtenvertrag::VERSION,
'AbsenderID' => $this->InstanceID,
'EmpfaengerID' => $managerID,
'Zeitpunkt' => time(),
],
'Betriebsart' => $this->ReadAttributeString('Betriebsart'),
'PrioritaetPV' => $this->ReadPropertyInteger('PrioritaetPV'),
'PrioritaetPeak' => $this->ReadPropertyInteger('PrioritaetPeak'),
'Leistungswerte_W' => $this->leseLeistungsangebot(),
'AenderungMoeglich' => (bool) $this->leseZustand('AenderungMoeglich'),
'Verfuegbar' => (bool) $this->leseZustand('Verfuegbar'),
'Istleistung_W' => (float) $this->leseZustand('Istleistung'),
'Leistungsquelle' => Nachrichtenvertrag::LEISTUNGSQUELLE_BERECHNET,
'Zustand' => [
[
'Kennung' => 'Sollleistung_W',
'Art' => 'Sollwert',
'Wert' => (bool) $this->leseZustand('SollwertGueltig')
? (int) $this->leseZustand('Sollleistung')
: null,
'Einheit' => 'W',
],
[
'Kennung' => 'Puffertemperatur_C',
'Art' => 'Istwert',
'Wert' => $temperaturenGueltig ? (float) $this->GetValue('Puffertemperatur') : null,
'Einheit' => 'C',
],
[
'Kennung' => 'Aussentemperatur_C',
'Art' => 'Istwert',
'Wert' => $temperaturenGueltig ? (float) $this->GetValue('Aussentemperatur') : null,
'Einheit' => 'C',
],
[
'Kennung' => 'Solltemperatur_C',
'Art' => 'Sollwert',
'Wert' => $temperaturenGueltig ? (float) $this->GetValue('Solltemperatur') : null,
'Einheit' => 'C',
],
[
'Kennung' => 'Heizbedarf',
'Art' => 'Status',
'Wert' => (bool) $this->GetValue('Heizbedarf'),
'Einheit' => '',
],
[
'Kennung' => 'AktiveStufe',
'Art' => 'Status',
'Wert' => (int) $this->leseZustand('AktiveStufe'),
'Einheit' => '',
],
[
'Kennung' => 'Fuehlerfehler',
'Art' => 'Stoerung',
'Wert' => !$temperaturenGueltig,
'Einheit' => '',
'Text' => $temperaturenGueltig
? ''
: 'Puffer- oder Aussentemperatur fehlt oder ist veraltet.',
],
[
'Kennung' => 'Schaltfehler',
'Art' => 'Stoerung',
'Wert' => $schaltfehler !== '',
'Einheit' => '',
'Text' => $schaltfehler,
],
],
];
}
/** @return list<int> */
private function zugeordneteManagerIDs(): array
{
$ergebnis = [];
foreach (IPS_GetInstanceListByModuleID(self::MANAGER_MODULE_ID) as $managerID) {
try {
$automatisch = (bool) IPS_GetProperty($managerID, 'AutomatischeSuche');
$property = $automatisch
? 'AutomatischeVerbraucherZuordnung'
: 'VerbraucherZuordnung';
$zuordnung = $this->leseManagerZuordnung($managerID, $property);
if ($automatisch && $zuordnung === []) {
$zuordnung = $this->leseManagerZuordnung($managerID, 'VerbraucherZuordnung');
}
} catch (Throwable $fehler) {
continue;
}
foreach ($zuordnung as $eintrag) {
if (!is_array($eintrag) || ($eintrag['Aktiv'] ?? false) !== true) {
continue;
}
$instanzID = $eintrag['InstanzID'] ?? $eintrag['Verbraucher'] ?? null;
if ($instanzID === $this->InstanceID) {
$ergebnis[] = (int) $managerID;
break;
}
}
}
return array_values(array_unique($ergebnis));
}
/** @return array<mixed> */
private function leseManagerZuordnung(int $managerID, string $property): array
{
$zuordnung = json_decode(
(string) IPS_GetProperty($managerID, $property),
true,
512,
JSON_THROW_ON_ERROR
);
return is_array($zuordnung) ? $zuordnung : [];
}
private function pruefeKonfiguration(): void
{
foreach (['PrioritaetPV', 'PrioritaetPeak'] as $property) {
if ($this->ReadPropertyInteger($property) < 0) {
throw new InvalidArgumentException($property . ' muss mindestens 0 sein.');
}
}
foreach ([
'Meldeintervall',
'VorgabeTimeout',
'TemperaturMaxAlter',
'ZeitKonstante',
] as $property) {
if ($this->ReadPropertyInteger($property) <= 0) {
throw new InvalidArgumentException($property . ' muss groesser als 0 sein.');
}
}
if ($this->ReadPropertyInteger('LastwechselSperrzeit') < 0) {
throw new InvalidArgumentException('LastwechselSperrzeit darf nicht negativ sein.');
}
if ($this->ReadPropertyFloat('Hysterese') <= 0.0) {
throw new InvalidArgumentException('Hysterese muss groesser als 0 sein.');
}
PufferspeicherRegler::solltemperatur(
20.0,
$this->ReadPropertyFloat('FusspunktVorlauftemperatur'),
$this->ReadPropertyFloat('HeizkurvenSteigung'),
$this->ReadPropertyFloat('HeizkurveMinimaltemperatur'),
$this->ReadPropertyFloat('HeizkurveMaximaltemperatur')
);
PufferspeicherRegler::einschaltschwelle(
50.0,
$this->ReadPropertyFloat('Hysterese'),
$this->ReadPropertyInteger('MindesttemperaturModus'),
$this->ReadPropertyFloat('Mindesttemperatur'),
$this->ReadPropertyFloat('MindesttemperaturDifferenz')
);
foreach (['PufferfuehlerVariableID', 'AussentemperaturVariableID'] as $property) {
$variablenID = $this->ReadPropertyInteger($property);
if ($variablenID <= 0 || !IPS_VariableExists($variablenID)) {
throw new InvalidArgumentException($property . ' ist nicht eingerichtet.');
}
$variable = IPS_GetVariable($variablenID);
if (!in_array((int) $variable['VariableType'], [1, 2], true)) {
throw new InvalidArgumentException($property . ' muss eine Integer- oder Floatvariable sein.');
}
}
$wpID = $this->ReadPropertyInteger('WaermepumpenSolltemperaturVariableID');
if ($wpID > 0) {
if (!IPS_VariableExists($wpID)) {
throw new InvalidArgumentException('WaermepumpenSolltemperaturVariableID existiert nicht.');
}
$wpVariable = IPS_GetVariable($wpID);
if (!in_array((int) $wpVariable['VariableType'], [1, 2], true)) {
throw new InvalidArgumentException(
'WaermepumpenSolltemperaturVariableID muss eine Integer- oder Floatvariable sein.'
);
}
}
$stufen = PufferspeicherRegler::dekodiereLeistungsstufen(
$this->ReadPropertyString('LeistungsStufen')
);
if ($stufen === []) {
throw new InvalidArgumentException('Mindestens eine Leistungsstufe ist erforderlich.');
}
foreach ($stufen as $stufe) {
if (!IPS_VariableExists($stufe['KontaktID'])) {
throw new InvalidArgumentException('Schaltkontakt ' . $stufe['KontaktID'] . ' existiert nicht.');
}
$kontakt = IPS_GetVariable($stufe['KontaktID']);
if ((int) $kontakt['VariableType'] !== 0) {
throw new InvalidArgumentException('Alle Schaltkontakte muessen Booleanvariablen sein.');
}
}
}
private function registriereTemperaturmeldungen(): void
{
$zuordnungen = [
'RegistriertePufferID' => $this->ReadPropertyInteger('PufferfuehlerVariableID'),
'RegistrierteAussenID' => $this->ReadPropertyInteger('AussentemperaturVariableID'),
];
foreach ($zuordnungen as $attribut => $neueID) {
$alteID = $this->ReadAttributeInteger($attribut);
if ($alteID > 0 && $alteID !== $neueID) {
$this->UnregisterMessage($alteID, self::VM_UPDATE);
}
if ($neueID > 0 && $neueID !== $alteID) {
$this->RegisterMessage($neueID, self::VM_UPDATE);
}
$this->WriteAttributeInteger($attribut, $neueID);
}
}
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->RegisterVariableInteger('AktiveStufe', 'Aktive Stufe', '', 200);
$this->RegisterVariableFloat('BezogeneEnergie', 'Bezogene Energie', '~Electricity', 210);
$this->RegisterVariableBoolean(
'TemperaturenGueltig',
'Temperaturen gueltig',
'~Switch',
220
);
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',
'TemperaturenGueltig' => 'ZustandTemperaturenGueltig',
];
$integerAttribute = [
'Sollleistung' => 'ZustandSollleistung',
'AktiveStufe' => 'ZustandAktiveStufe',
];
$floatAttribute = [
'Istleistung' => 'ZustandIstleistung',
'BezogeneEnergie' => 'ZustandBezogeneEnergie',
];
$stringAttribute = ['Stoertext' => 'ZustandStoertext'];
if (isset($booleanAttribute[$ident])) {
$wert = (bool) $wert;
$this->WriteAttributeBoolean($booleanAttribute[$ident], $wert);
} elseif (isset($integerAttribute[$ident])) {
$wert = (int) $wert;
$this->WriteAttributeInteger($integerAttribute[$ident], $wert);
} elseif (isset($floatAttribute[$ident])) {
$wert = (float) $wert;
$this->WriteAttributeFloat($floatAttribute[$ident], $wert);
} elseif (isset($stringAttribute[$ident])) {
$wert = (string) $wert;
$this->WriteAttributeString($stringAttribute[$ident], $wert);
} elseif ($ident !== 'Leistungsquelle') {
throw new LogicException('Unbekannter interner Zustand: ' . $ident);
}
$variablenID = @IPS_GetObjectIDByIdent($ident, $this->InstanceID);
if (is_int($variablenID) && $variablenID > 0 && IPS_VariableExists($variablenID)) {
SetValue($variablenID, $wert);
}
}
/** @return bool|int|float|string */
private function leseZustand(string $ident)
{
$booleanAttribute = [
'SollwertGueltig' => 'ZustandSollwertGueltig',
'Verfuegbar' => 'ZustandVerfuegbar',
'AenderungMoeglich' => 'ZustandAenderungMoeglich',
'Stoerung' => 'ZustandStoerung',
'TemperaturenGueltig' => 'ZustandTemperaturenGueltig',
];
$integerAttribute = [
'Sollleistung' => 'ZustandSollleistung',
'AktiveStufe' => 'ZustandAktiveStufe',
];
$floatAttribute = [
'Istleistung' => 'ZustandIstleistung',
'BezogeneEnergie' => 'ZustandBezogeneEnergie',
];
$stringAttribute = ['Stoertext' => 'ZustandStoertext'];
if (isset($booleanAttribute[$ident])) {
return $this->ReadAttributeBoolean($booleanAttribute[$ident]);
}
if (isset($integerAttribute[$ident])) {
return $this->ReadAttributeInteger($integerAttribute[$ident]);
}
if (isset($floatAttribute[$ident])) {
return $this->ReadAttributeFloat($floatAttribute[$ident]);
}
if (isset($stringAttribute[$ident])) {
return $this->ReadAttributeString($stringAttribute[$ident]);
}
throw new LogicException('Unbekannter interner Zustand: ' . $ident);
}
/** @return list<int> */
private function leseLeistungsangebot(): array
{
$angebot = json_decode(
$this->ReadAttributeString('Leistungsangebot'),
true,
512,
JSON_THROW_ON_ERROR
);
if (!is_array($angebot)) {
return [0];
}
return array_values(array_map('intval', $angebot));
}
private function deaktiviereTimer(): void
{
foreach (['LastwechselFreigabe', 'Meldezyklus', 'RueckmeldungVerzoegert'] 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
);
}
}
+35 -7
View File
@@ -4,20 +4,27 @@ Energiemanagement, Manager und steuerbare Verbraucher fuer IP-Symcon.
## Status ## 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, der
1-stufige Verbraucher und die Ladestation Stand-Alone sind implementiert. Die
weiteren Module sind als Diskussionsentwürfe dokumentiert.
## Geplante Module ## Geplante Module
- Manager - Manager (implementiert)
- Batterie - Batterie
- Wassererwaermer - Wassererwaermer (implementiert)
- Pufferspeicher - Pufferspeicher
- Verbraucher 1-Stufig - Verbraucher 1-Stufig (implementiert)
- Waermepumpe - Waermepumpe
- Ladestation Stand-Alone - Ladestation Stand-Alone (implementiert)
- Ladestation Gateway - Ladestation Gateway
- Easee 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. 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 ## Voraussetzungen
@@ -32,7 +39,18 @@ composer install
composer check composer check
``` ```
Neue oder geaenderte Funktionen werden zusammen mit ihren PHPUnit-Tests eingecheckt. Die CI-Pruefung muss vor der Uebernahme in einen Freigabebranch erfolgreich sein. Der Gesamttest schliesst alle Modulsuiten ein. Der einstufige Verbraucher kann
zusaetzlich unabhaengig ausgefuehrt werden:
```bash
composer check:verbraucher-einstufig
```
Neue oder geaenderte Funktionen werden zusammen mit ihren PHPUnit-Tests eingecheckt.
Jedes Modul besitzt zusätzlich einen standardisierten Laufzeittest für IP-Symcon
8.x. Einzel-, Änderungs- und Gesamtläufe sowie Cleanup und Berichte sind im
[Testleitfaden](docs/testing/README.md) beschrieben. Die CI-Pruefung muss vor
der Uebernahme in einen Freigabebranch erfolgreich sein.
| Git-Branch | IP-Symcon-Kanal | | Git-Branch | IP-Symcon-Kanal |
| --- | --- | | --- | --- |
@@ -40,5 +58,15 @@ Neue oder geaenderte Funktionen werden zusammen mit ihren PHPUnit-Tests eingeche
| `beta` | Beta | | `beta` | Beta |
| `develop` | Testing | | `develop` | Testing |
Die ausfuehrliche Schnittstellenbeschreibung steht unter [docs/Schnittstelle.md](docs/Schnittstelle.md). ## Dokumentation
- [Zentrale Liste offener Teamentscheidungen](docs/Offene-Punkte.md)
- [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)
- [Ladestation-Stand-Alone-Modul](docs/module/Ladestation-Stand-Alone/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)
+137
View File
@@ -0,0 +1,137 @@
{
"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": "Mindesteinschaltdauer",
"caption": "Mindest-Einschaltdauer",
"suffix": " s",
"minimum": 0
},
{
"type": "NumberSpinner",
"name": "Mindestausschaltdauer",
"caption": "Mindest-Ausschaltdauer",
"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": "CheckBox",
"name": "PeakSperreBeiMindestlaufzeitAnbieten",
"caption": "Bei Peak Sperre waehrend der Mindestlaufzeit anbieten"
}
]
},
{
"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"
}
+986
View File
@@ -0,0 +1,986 @@
<?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',
'SchaltbefehlAusstehend',
'RestMindestzeit',
];
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->RegisterPropertyBoolean('PeakSperreBeiMindestlaufzeitAnbieten', true);
$this->RegisterPropertyInteger('Mindesteinschaltdauer', 5);
$this->RegisterPropertyInteger('Mindestausschaltdauer', 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('BestaetigtSeit', 0);
$this->RegisterAttributeInteger('FreigabeAb', 0);
$this->RegisterAttributeInteger('LaufzeitStandZeit', 0);
$this->RegisterAttributeString('LaufzeitTag', '');
$this->RegisterAttributeString('Leistungsangebot', '[0]');
$this->RegisterAttributeString('Betriebsart', Nachrichtenvertrag::BETRIEBSART_PV);
$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->RegisterAttributeBoolean('ZustandSchaltbefehlAusstehend', false);
$this->RegisterAttributeInteger('ZustandRestMindestzeit', 0);
$this->RegisterTimer(
'Meldezyklus',
0,
"IPS_RequestAction(\$_IPS['TARGET'], 'Melden', false);"
);
$this->RegisterTimer(
'RueckmeldungVerzoegert',
0,
"IPS_RequestAction(\$_IPS['TARGET'], 'Melden', true);"
);
// Bestehende Instanzen behalten den alten Timer deaktiviert.
$this->RegisterTimer('Umschaltfreigabe', 0, '');
$this->RegisterTimer(
'MindestzeitAbgelaufen',
0,
"IPS_RequestAction(\$_IPS['TARGET'], 'MindestzeitAbgelaufen', 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 'MindestzeitAbgelaufen':
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->WriteAttributeString('Betriebsart', $daten['Betriebsart']);
$this->aktualisiere(false);
$sollleistung = $daten['Sollleistung_W'];
if ($sollleistung === null) {
if ((bool) $this->leseZustand('SollwertGueltig')
&& !in_array((int) $this->leseZustand('Sollleistung'), $this->leseLeistungsangebot(), true)
) {
$this->setzeZustand('SollwertGueltig', false);
}
$this->planeMeldung();
return;
}
if (!in_array($sollleistung, $this->leseLeistungsangebot(), true)) {
throw new InvalidArgumentException(
'Sollleistung_W liegt nicht im aktuell gemeldeten Leistungsangebot.'
);
}
$this->WriteAttributeInteger('LetzteManagerID', $managerID);
$this->WriteAttributeInteger('LetzteVorgabeZeit', time());
$this->setzeZustand('Sollleistung', $sollleistung);
$this->setzeZustand('SollwertGueltig', true);
$this->planeVorgabeTimeout(time());
$this->aktualisiere(false);
$this->planeMeldung();
}
private function aktualisiere(bool $meldungPlanen): void
{
try {
$jetzt = time();
$this->aktualisiereTageslaufzeit($jetzt);
$vorherigerSchaltzustand = (bool) $this->leseZustand('Schaltzustand');
$schaltzustand = $this->leseSchaltzustand();
if ($schaltzustand !== $vorherigerSchaltzustand) {
$this->bestaetigeZustandswechsel($schaltzustand, $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');
$wartetAufRueckmeldung = $this->ReadAttributeBoolean('SchaltpruefungAusstehend');
$rueckmeldefehler = $this->ReadPropertyInteger('RueckmeldungVariableID') > 0
&& !$wartetAufRueckmeldung
&& $schaltzustand !== $this->leseAktorSchaltzustand();
$this->setzeZustand('Rueckmeldefehler', $rueckmeldefehler);
$fehlerfrei = $this->ReadAttributeString('Schaltfehler') === ''
&& !$rueckmeldefehler;
$verfuegbar = $aktiv && $fehlerfrei;
$mindestlaufzeitErzwungen = $verfuegbar
&& $this->mussMindestlaufzeitErzwingen($jetzt);
$restMindestzeit = $wartetAufRueckmeldung
? 0
: EinStufigRegler::restMindestzeit(
$this->ReadAttributeInteger('FreigabeAb'),
$jetzt
);
$peakbetrieb = $this->ReadAttributeString('Betriebsart')
=== Nachrichtenvertrag::BETRIEBSART_PEAK;
$peakSperreBeiMindestlaufzeitAnbieten = $this->ReadPropertyBoolean(
'PeakSperreBeiMindestlaufzeitAnbieten'
);
$mindestlaufzeitLokalErzwingen = $mindestlaufzeitErzwungen
&& (!$peakbetrieb || !$peakSperreBeiMindestlaufzeitAnbieten);
$ziel = 0;
if ($verfuegbar) {
if ($mindestlaufzeitLokalErzwingen) {
$ziel = $this->ReadPropertyInteger('Nennleistung');
} elseif ((bool) $this->leseZustand('SollwertGueltig')) {
$ziel = (int) $this->leseZustand('Sollleistung');
}
}
$istleistung = $this->leistungFuerSchaltzustand($schaltzustand);
$zielzustand = $ziel > 0;
$befehlNoetig = $ziel !== $istleistung
|| ($wartetAufRueckmeldung
&& $zielzustand !== $this->ReadAttributeBoolean('Schaltziel'));
// Die lokale Deaktivierung bleibt der einzige bewusste Mindestzeit-Override.
$sicheresAusschalten = !$zielzustand && !$aktiv;
if ($befehlNoetig
&& ($sicheresAusschalten
|| ($restMindestzeit === 0 && !$wartetAufRueckmeldung && $fehlerfrei))
) {
$this->schalte($zielzustand, $jetzt);
$schaltzustand = $this->leseSchaltzustand();
$this->pruefeAusstehendenSchaltvorgang($schaltzustand, $jetzt);
$istleistung = $this->leistungFuerSchaltzustand($schaltzustand);
$wartetAufRueckmeldung = $this->ReadAttributeBoolean('SchaltpruefungAusstehend');
$restMindestzeit = $wartetAufRueckmeldung
? 0
: EinStufigRegler::restMindestzeit(
$this->ReadAttributeInteger('FreigabeAb'),
$jetzt
);
}
$this->setzeZustand('Sollleistung', $ziel);
$this->setzeZustand('Verfuegbar', $verfuegbar);
$this->setzeZustand('SchaltbefehlAusstehend', $wartetAufRueckmeldung);
$this->setzeZustand('RestMindestzeit', $restMindestzeit);
$aenderungMoeglich = $verfuegbar
&& (!$mindestlaufzeitErzwungen
|| ($peakbetrieb && $peakSperreBeiMindestlaufzeitAnbieten))
&& $restMindestzeit === 0
&& !$wartetAufRueckmeldung;
$this->setzeZustand('AenderungMoeglich', $aenderungMoeglich);
$angebot = EinStufigRegler::leistungsangebot(
$this->ReadPropertyInteger('Nennleistung'),
$istleistung,
$verfuegbar,
$aenderungMoeglich,
$mindestlaufzeitErzwungen,
$peakbetrieb,
$peakSperreBeiMindestlaufzeitAnbieten
);
$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->setzeZustand('SchaltbefehlAusstehend', true);
$this->WriteAttributeBoolean('SchaltvorgangLaeuft', true);
try {
RequestAction($kontaktID, $aktorwert);
} catch (Throwable $fehler) {
$this->WriteAttributeBoolean('SchaltpruefungAusstehend', false);
$this->setzeZustand('SchaltbefehlAusstehend', false);
throw new RuntimeException('Schaltkontakt konnte nicht gesetzt werden: ' . $fehler->getMessage());
} finally {
$this->WriteAttributeBoolean('SchaltvorgangLaeuft', false);
}
$istzustand = $this->leseSchaltzustand();
if ($istzustand === $einschalten) {
$this->bestaetigeZustandswechsel($istzustand, $jetzt);
} elseif ($this->ReadPropertyInteger('RueckmeldungVariableID') <= 0) {
$this->WriteAttributeBoolean('SchaltpruefungAusstehend', false);
$this->setzeZustand('SchaltbefehlAusstehend', false);
throw new RuntimeException('Schaltkontakt hat den angeforderten Zustand nicht uebernommen.');
} else {
// Die physische Rueckmeldung bestimmt Zustand und Beginn der Mindestzeit.
$this->setzeZustand('Rueckmeldefehler', false);
}
$this->WriteAttributeString('Schaltfehler', '');
$this->protokolliere('Schaltbefehl', ['Eingeschaltet' => $einschalten]);
}
private function pruefeAusstehendenSchaltvorgang(bool $schaltzustand, int $jetzt): void
{
if ($this->ReadAttributeBoolean('SchaltpruefungAusstehend')
&& $schaltzustand === $this->ReadAttributeBoolean('Schaltziel')
) {
$this->bestaetigeZustandswechsel($schaltzustand, $jetzt);
}
$this->setzeZustand(
'SchaltbefehlAusstehend',
$this->ReadAttributeBoolean('SchaltpruefungAusstehend')
);
}
private function bestaetigeZustandswechsel(bool $schaltzustand, int $jetzt): void
{
$vorherigerZustand = (bool) $this->leseZustand('Schaltzustand');
$zustandGeaendert = $schaltzustand !== $vorherigerZustand
|| $this->ReadAttributeInteger('BestaetigtSeit') <= 0;
$this->setzeSchaltzustand($schaltzustand);
if ($zustandGeaendert) {
$this->WriteAttributeInteger('BestaetigtSeit', $jetzt);
$this->WriteAttributeInteger(
'FreigabeAb',
EinStufigRegler::freigabezeitpunkt(
$schaltzustand,
$jetzt,
$this->ReadPropertyInteger('Mindesteinschaltdauer'),
$this->ReadPropertyInteger('Mindestausschaltdauer')
)
);
}
if ($schaltzustand === $this->ReadAttributeBoolean('Schaltziel')) {
$this->WriteAttributeBoolean('SchaltpruefungAusstehend', false);
}
$this->setzeZustand(
'SchaltbefehlAusstehend',
$this->ReadAttributeBoolean('SchaltpruefungAusstehend')
);
$this->setzeZustand('Rueckmeldefehler', false);
$this->protokolliere('Schaltzustand bestaetigt', ['Eingeschaltet' => $schaltzustand]);
}
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
{
$this->SetTimerInterval('Umschaltfreigabe', 0);
$freigabeAb = $this->ReadAttributeInteger('FreigabeAb');
$wartetAufRueckmeldung = $this->ReadAttributeBoolean('SchaltpruefungAusstehend');
$this->SetTimerInterval(
'MindestzeitAbgelaufen',
!$wartetAufRueckmeldung && $freigabeAb > $jetzt
? max(1, ($freigabeAb - $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);
}
if ($this->ReadAttributeInteger('BestaetigtSeit') <= 0) {
// Migration bestehender Instanzen ohne bestaetigten Zeitbezug.
$this->WriteAttributeBoolean('Schaltziel', $schaltzustand);
$this->WriteAttributeBoolean('SchaltpruefungAusstehend', false);
$this->bestaetigeZustandswechsel($schaltzustand, $jetzt);
} else {
$this->setzeSchaltzustand($schaltzustand);
$this->setzeZustand(
'SchaltbefehlAusstehend',
$this->ReadAttributeBoolean('SchaltpruefungAusstehend')
);
}
}
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);
}
return $this->leseAktorSchaltzustand();
}
private function leseAktorSchaltzustand(): bool
{
$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(),
],
'Betriebsart' => $this->ReadAttributeString('Betriebsart'),
'PrioritaetPV' => $this->ReadPropertyInteger('PrioritaetPV'),
'PrioritaetPeak' => $this->ReadPropertyInteger('PrioritaetPeak'),
'Leistungswerte_W' => $this->leseLeistungsangebot(),
'AenderungMoeglich' => (bool) $this->leseZustand('AenderungMoeglich'),
'Verfuegbar' => (bool) $this->leseZustand('Verfuegbar'),
'Istleistung_W' => (float) $this->leseZustand('Istleistung'),
'Leistungsquelle' => Nachrichtenvertrag::LEISTUNGSQUELLE_BERECHNET,
'Zustand' => [
[
'Kennung' => 'Sollleistung_W',
'Art' => 'Sollwert',
'Wert' => (bool) $this->leseZustand('SollwertGueltig')
? (int) $this->leseZustand('Sollleistung')
: null,
'Einheit' => 'W',
],
[
'Kennung' => 'Schaltzustand',
'Art' => 'Status',
'Wert' => (bool) $this->leseZustand('Schaltzustand'),
'Einheit' => '',
],
[
'Kennung' => 'SchaltbefehlAusstehend',
'Art' => 'Status',
'Wert' => (bool) $this->leseZustand('SchaltbefehlAusstehend'),
'Einheit' => '',
],
[
'Kennung' => 'Schaltbereit',
'Art' => 'Status',
'Wert' => (bool) $this->leseZustand('AenderungMoeglich'),
'Einheit' => '',
],
[
'Kennung' => 'RestMindestzeit_s',
'Art' => 'Istwert',
'Wert' => (int) $this->leseZustand('RestMindestzeit'),
'Einheit' => 's',
],
[
'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.');
}
foreach (['Mindesteinschaltdauer', 'Mindestausschaltdauer'] as $property) {
if ($this->ReadPropertyInteger($property) < 0) {
throw new InvalidArgumentException($property . ' 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);
$this->RegisterVariableBoolean(
'SchaltbefehlAusstehend',
'Schaltbefehl ausstehend',
'~Switch',
210
);
$this->RegisterVariableInteger('RestMindestzeit', 'Restliche Mindestzeit', '', 220);
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',
'SchaltbefehlAusstehend' => 'ZustandSchaltbefehlAusstehend',
];
$integerAttribute = [
'Sollleistung' => 'ZustandSollleistung',
'Tageslaufzeit' => 'ZustandTageslaufzeit',
'RestMindestzeit' => 'ZustandRestMindestzeit',
];
$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',
'SchaltbefehlAusstehend' => 'ZustandSchaltbefehlAusstehend',
];
$integerAttribute = [
'Sollleistung' => 'ZustandSollleistung',
'Tageslaufzeit' => 'ZustandTageslaufzeit',
'RestMindestzeit' => 'ZustandRestMindestzeit',
];
$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',
'MindestzeitAbgelaufen',
'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
+9
View File
@@ -20,10 +20,19 @@
}, },
"scripts": { "scripts": {
"lint": "find . -path ./vendor -prune -o -name '*.php' -print0 | xargs -0 -n1 php -l", "lint": "find . -path ./vendor -prune -o -name '*.php' -print0 | xargs -0 -n1 php -l",
"lint:verbraucher-einstufig": "find VerbraucherEinStufig libs/EinStufigRegler.php tests/VerbraucherEinStufig -name '*.php' -print0 | xargs -0 -n1 php -l",
"test": "phpunit", "test": "phpunit",
"test:verbraucher-einstufig": "phpunit --configuration phpunit.verbraucher-einstufig.xml",
"symcon:all": "bash tests/Symcon/bin/run-symcon-tests.sh all",
"symcon:single": "bash tests/Symcon/bin/run-symcon-tests.sh single",
"symcon:affected": "bash tests/Symcon/bin/run-symcon-tests.sh affected",
"check": [ "check": [
"@lint", "@lint",
"@test" "@test"
],
"check:verbraucher-einstufig": [
"@lint:verbraucher-einstufig",
"@test:verbraucher-einstufig"
] ]
}, },
"config": { "config": {
+34
View File
@@ -0,0 +1,34 @@
# Obere Anschlüsse des Managers
> Status: Lizenzierung implementiert; die übrigen Anbieterformate sind noch
> nicht festgelegt.
Prognose, SDL/VGT und Stoerueberwachung sind optional. Die Lizenzierung ist fuer
den Betrieb des Managers erforderlich. 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 | Pruefung freigeschalteter Manager-Funktionen | `POST https://license.enelix.ch/api/v1/licenses/activate` |
| 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.
- Eine fehlende oder abgelaufene Lizenz blockiert die Managerregelung. Eine
bestaetigte Entwicklungs-Lease bleibt bei Verbindungsunterbruch bis
`offlineUntil` gueltig.
## Noch festzulegen
- Anbieter und Datenformat für Prognose und Störüberwachung
- Aufbau, Gültigkeitszeitraum und Rückmeldung eines SDL-Auftrags
- Authentisierung der noch optionalen Anschlüsse
- Ablösung der Entwicklungs-Lease durch den signierten produktiven Lizenzvertrag
+125
View File
@@ -0,0 +1,125 @@
# Offene Punkte
Diese Datei ist die zentrale, repositoryuebergreifende Liste fuer Themen, die das
Enelix-Team noch entscheiden muss. Sie dient als Arbeitsgrundlage fuer
Sprintmeetings. Modulinterne Implementierungsdetails bleiben in der jeweiligen
Moduldokumentation.
## Arbeitsweise
- Neue Punkte erhalten fortlaufende IDs im Format `OP-001`.
- Erlaubte Status sind `Offen`, `In Klaerung`, `Entschieden` und `Zurueckgestellt`.
- Ein Eintrag beschreibt die konkrete Entscheidungsfrage, nicht nur ein Stichwort.
- Entscheidungen bleiben in dieser Datei erhalten und verweisen auf ADR, Issue oder Commit.
- Neue Punkte werden standardmaessig ohne Verantwortlichen und Ziel-Sprint erfasst.
- Im Sprintmeeting werden Status, Verantwortlicher und Ziel-Sprint gepflegt.
## Aufnahmeschranke fuer offene Punkte und Issues
Ein neuer Eintrag wird nur angelegt, wenn er konkret, abgrenzbar und pruefbar ist.
- Eine offene Entscheidung nennt eine konkrete Entscheidungsfrage, ihren Kontext und die Auswirkung der Entscheidung.
- Ein Bug beschreibt betroffenes Verhalten, Ist- und Sollzustand sowie eine nachvollziehbare Ausloesung.
- Ein Feature oder Task nennt Anwendungsfall, Ausloeser oder Eingabe, erwartetes Verhalten und ein pruefbares Ergebnis.
- Ein Eintrag behandelt genau ein zusammenhaengendes Thema und kann unabhaengig umgesetzt oder entschieden werden.
- Zu grosse oder gebuendelte Anforderungen werden vor der Aufnahme in kleinere, einzeln pruefbare Punkte zerlegt.
Abstrakte Ziele, Visionen und Loesungsideen ohne konkretes Verhalten werden nicht aufgenommen.
Die Rueckmeldung lautet in diesem Fall sinngemaess:
> Nicht aufgenommen: Der Punkt ist noch zu abstrakt. Bitte in konkrete,
> einzeln ausfuehrbare und pruefbare Probleme, Entscheidungen oder Schritte unterteilen.
Beispiel fuer einen ausreichend konkreten Ausgangspunkt:
> Die Temperatureinheit des Boilers kann zwischen Celsius und Fahrenheit
> eingestellt werden; Anzeige und Grenzwerte verwenden die gewaehlte Einheit.
Beispiel fuer einen noch zu abstrakten Punkt:
> Der Manager soll aufgrund einer Prognose regeln.
Hier fehlen unter anderem Prognoseart, geregelte Verbraucher, konkretes Regelziel,
Prioritaeten, Fehlerverhalten und ein pruefbares Ergebnis.
## Uebersicht
| ID | Status | Bereich | Kurzthema |
| --- | --- | --- | --- |
| OP-001 | Entschieden | Lizenzierung | Betriebsort des Lizenzservers |
| OP-002 | Offen | Grundeinrichtung | Generator und kostenpflichtige Ersteinrichtung |
| OP-003 | Offen | Lizenzportal | VGT-Integration in license.enelix.ch |
| OP-004 | Offen | Lizenzierung | Lizenzmodell des Verbrauchskostenreports |
| OP-005 | Offen | Teststrategie | Standort fuer Feldtests |
## Offene Punkte
### OP-002: Generator und kostenpflichtige Ersteinrichtung
- **Entscheidungsfrage:** Soll der automatische Generator fuer die
Grundeinrichtung zugleich die kostenpflichtige Ersteinrichtung abbilden?
- **Kontext:** Der vorgesehene automatische Generator fuer die Grundeinrichtung
muss gegenueber der kostenpflichtigen Ersteinrichtung abgegrenzt werden.
- **Auswirkung:** Die Entscheidung legt fest, ob beide als derselbe Vorgang oder
als getrennte Leistungen behandelt werden.
- **Verantwortlich:** Offen
- **Ziel-Sprint:** Offen
- **Ergebnis/Verweis:** Offen
### OP-003: VGT-Integration in license.enelix.ch
- **Entscheidungsfrage:** Soll die VGT-Anwendung als Bestandteil in die
Oberflaeche von `license.enelix.ch` integriert werden?
- **Kontext:** Zu klaeren ist, ob die VGT-Anwendung innerhalb der Oberflaeche von
`license.enelix.ch` oder getrennt davon bereitgestellt wird.
- **Auswirkung:** Die Entscheidung legt fest, ob die VGT-Anwendung Teil dieser
Oberflaeche oder eine eigenstaendige Anwendung bleibt.
- **Verantwortlich:** Offen
- **Ziel-Sprint:** Offen
- **Ergebnis/Verweis:** Offen
### OP-004: Lizenzmodell des Verbrauchskostenreports
- **Entscheidungsfrage:** Soll der Verbrauchskostenreport nach dem Lizenzmodell
des Managers oder mit einer einzigen, von der Anzahl angeschlossener Zaehler
unabhaengigen Modullizenz angeboten werden?
- **Kontext:** Fuer den Verbrauchskostenreport muss zwischen dem Lizenzmodell des
Managers und einer zaehlerunabhaengigen Modullizenz entschieden werden.
- **Auswirkung:** Die Entscheidung legt fest, ob die Anzahl angeschlossener
Zaehler den Lizenzumfang des Verbrauchskostenreports beeinflusst.
- **Verantwortlich:** Offen
- **Ziel-Sprint:** Offen
- **Ergebnis/Verweis:** Offen
### OP-005: Standort fuer Feldtests
- **Entscheidungsfrage:** An welchem Standort koennen die Module aus Enelix EMS
und Enelix Utils unter realen Einsatzbedingungen getestet werden?
- **Kontext:** Fuer die Module aus beiden Repositories soll ein geeigneter
Standort fuer Feldtests festgelegt werden.
- **Auswirkung:** Die Entscheidung legt fest, wo die praktische Validierung der
Module unter realen Einsatzbedingungen stattfindet.
- **Verantwortlich:** Offen
- **Ziel-Sprint:** Offen
- **Ergebnis/Verweis:** Offen
## Entschiedene Punkte
### OP-001: Betriebsort des Lizenzservers
- **Entscheidung:** Der Entwicklungs-Lizenzdienst wird unter
`https://license.enelix.ch` betrieben.
- **Manager-Endpunkt:** `POST /api/v1/licenses/activate`
- **Ergebnis/Verweis:** [Obere Anschluesse](Obere-Anschluesse.md) und
[Manager](module/Manager/README.md)
## Vorlage fuer neue Punkte
```markdown
### OP-NNN: Kurztitel
- **Entscheidungsfrage:** Welche konkrete Entscheidung muss das Team treffen?
- **Verantwortlich:** Offen
- **Ziel-Sprint:** Offen
- **Ergebnis/Verweis:** Offen
```
+83 -25
View File
@@ -1,6 +1,6 @@
# EMS-Schnittstelle # EMS-Schnittstelle
Vertragsversion: `3.0` Vertragsversion: `4.0`
Es gibt genau eine fachliche Empfangsmethode je Richtung: Es gibt genau eine fachliche Empfangsmethode je Richtung:
@@ -13,7 +13,7 @@ public function VerbraucherdatenEmpfangen(array $daten): void;
| Feld | Typ | Bedeutung | | Feld | Typ | Bedeutung |
| --- | --- | --- | | --- | --- | --- |
| `Version` | Text | Vertragsversion `3.0` | | `Version` | Text | Vertragsversion `4.0` |
| `AbsenderID` | Ganzzahl | Sendende Symcon-Instanz | | `AbsenderID` | Ganzzahl | Sendende Symcon-Instanz |
| `EmpfaengerID` | Ganzzahl | Empfangende Symcon-Instanz | | `EmpfaengerID` | Ganzzahl | Empfangende Symcon-Instanz |
| `Zeitpunkt` | Ganzzahl | Unixzeit in UTC | | `Zeitpunkt` | Ganzzahl | Unixzeit in UTC |
@@ -23,61 +23,119 @@ public function VerbraucherdatenEmpfangen(array $daten): void;
```json ```json
{ {
"Kopf": { "Kopf": {
"Version": "3.0", "Version": "4.0",
"AbsenderID": 10001, "AbsenderID": 10001,
"EmpfaengerID": 20001, "EmpfaengerID": 20001,
"Zeitpunkt": 1788825600 "Zeitpunkt": 1788825600
}, },
"Betriebsart": "PV",
"Sollleistung_W": 1501 "Sollleistung_W": 1501
} }
``` ```
`Sollleistung_W` ist immer eine Ganzzahl und muss im aktuell gemeldeten Leistungsangebot liegen. `Betriebsart` ist `PV` oder `Peak`. `Sollleistung_W` ist eine Ganzzahl
aus dem fuer diese Betriebsart gemeldeten Leistungsangebot oder `null`.
`null` kuendigt nur die Betriebsart an. Der Verbraucher uebernimmt sie,
berechnet sein Leistungsangebot neu und meldet es zurueck. Eine vorhandene
Sollleistung wird dabei nur verworfen, wenn sie im neuen Angebot nicht mehr
zulaessig ist.
## Verbraucher an Manager ## Verbraucher an Manager
```json ```json
{ {
"Kopf": { "Kopf": {
"Version": "3.0", "Version": "4.0",
"AbsenderID": 20001, "AbsenderID": 20001,
"EmpfaengerID": 10001, "EmpfaengerID": 10001,
"Zeitpunkt": 1788825602 "Zeitpunkt": 1788825602
}, },
"Betriebsart": "Peak",
"PrioritaetPV": 0, "PrioritaetPV": 0,
"PrioritaetPeak": 0, "PrioritaetPeak": 0,
"Leistungswerte_W": [ "Leistungswerte_W": [0],
-3000, "AenderungMoeglich": false,
-2000,
{"Von_W": -1000, "Bis_W": -500},
0,
100,
{"Von_W": 1000, "Bis_W": 2000},
3000
],
"AenderungMoeglich": true,
"Verfuegbar": true, "Verfuegbar": true,
"Istleistung_W": 1498.5, "Istleistung_W": 0,
"Leistungsquelle": 2, "Leistungsquelle": 1,
"Zustand": [ "Zustand": [
{ {
"Kennung": "Sollleistung_W", "Kennung": "Sollleistung_W",
"Art": "Sollwert", "Art": "Sollwert",
"Wert": 1501, "Wert": 0,
"Einheit": "W" "Einheit": "W"
} }
] ]
} }
``` ```
### Feste Regeln ## Betriebsart-Synchronisation
- Prioritaeten beginnen bei 0; eine kleinere Zahl bedeutet eine hoehere Prioritaet. 1. Der Manager bestimmt `PV` oder `Peak`.
2. Meldungen einer anderen Betriebsart werden nicht zur Verteilung verwendet.
3. Der Manager sendet diesen Verbrauchern eine Betriebsart-Ankuendigung mit
`Sollleistung_W=null`.
4. Jeder Verbraucher berechnet und meldet seine PowerSteps fuer diese
Betriebsart.
5. Erst wenn alle aktiven Verbraucher synchronisiert sind, verteilt der Manager
Sollleistungen.
Damit kann jeder Verbrauchertyp unterschiedliche Angebote fuer PV und Peak
melden, ohne dass der Manager seine interne Geraetelogik kennen muss.
## Feste Regeln
- Prioritaeten beginnen bei 0; eine kleinere Zahl bedeutet hoehere Prioritaet.
- `Leistungsquelle`: 0 nicht vorhanden, 1 berechnet, 2 gemessen. - `Leistungsquelle`: 0 nicht vorhanden, 1 berechnet, 2 gemessen.
- Bei Leistungsquelle 0 ist `Istleistung_W` zwingend `null`. - Bei Leistungsquelle 0 ist `Istleistung_W` zwingend `null`.
- Leistungsbereiche enthalten jeden ganzen Wattwert von `Von_W` bis `Bis_W` einschliesslich. - Leistungsbereiche enthalten jeden ganzen Wattwert von `Von_W` bis `Bis_W`.
- Die Leistungswerte sind aufsteigend, eindeutig und ueberschneiden sich nicht. - Leistungswerte sind aufsteigend, eindeutig und ueberschneiden sich nicht.
- `Zustand` enthaelt immer den gemeinsamen Eintrag `Sollleistung_W` und daneben nur benoetigte geraetespezifische Eintraege. - `Zustand` enthaelt immer `Sollleistung_W`.
- Eine aktive Stoerung wird mit `Art=Stoerung` und `Wert=true` gemeldet; eine behobene mit `false`. - Verbraucher werden ausschliesslich im Manager zugeordnet.
- Verbraucher werden ausschliesslich im Manager zugeordnet. Der Verbraucher besitzt keine Manager-ID-Property. - Der Verbraucher besitzt keine Manager-ID-Property.
## Technische Umsetzung in IP-Symcon
Der Transport erfolgt ueber `IPS_RequestAction` mit JSON. `MessageSink`
erkennt registrierte Aenderungen. Empfang und Neuberechnung sind intern
entkoppelt, damit keine gegenseitige Endlosschleife entsteht.
Das PHP-Interface legt nur die Empfangsmethode fest. Die gemeinsamen
Symcon-Datenpunkte registriert `VerbraucherBasisTrait`.
### Gemeinsame Properties aller Verbraucher
| Ident | Typ | Standard | Beschreibung |
| --- | --- | --- | --- |
| `PrioritaetPV` | Integer | `0` | Prioritaet in der Betriebsart PV |
| `PrioritaetPeak` | Integer | `0` | Prioritaet in der Betriebsart Peak |
| `Meldeintervall` | Integer | `10` | Vollstaendige Rueckmeldung in Sekunden |
| `VorgabeTimeout` | Integer | `120` | Ablaufzeit einer Sollleistung |
| `EinstellungenInVisu` | Boolean | `false` | Lokale Einstellungen in der Visualisierung |
| `LoggingEin` | Boolean | `false` | Laufendes Diagnoseprotokoll |
### Gemeinsame Variablen aller Verbraucher
| Ident | Typ / Zugriff | Beschreibung |
| --- | --- | --- |
| `Aktiv` | Boolean / bedienbar | Lokale EMS-Freigabe; Start `false` |
| `Istleistung` | Float / Anzeige | Aktuelle Leistung in W |
| `Leistungsquelle` | Integer / Anzeige | 0 nicht vorhanden, 1 berechnet, 2 gemessen |
| `Sollleistung` | Integer / Anzeige | Angenommene oder lokal erzwungene Vorgabe |
| `SollwertGueltig` | Boolean / Anzeige | Aktuelle, nicht abgelaufene Vorgabe |
| `Verfuegbar` | Boolean / Anzeige | Verbraucher grundsaetzlich verfuegbar |
| `AenderungMoeglich` | Boolean / Anzeige | Neue Vorgabe darf uebernommen werden |
| `Stoerung` | Boolean / Anzeige | Mindestens eine Stoerung aktiv |
| `Stoertext` | String / Anzeige | Zusammengefasste Stoerbeschreibung |
`Leistungswerte_W`, `Betriebsart` und `Zustand` werden intern gehalten
und direkt in die Nachricht geschrieben.
## Zeitverhalten
- Rueckmeldung nach Start, relevanten Aenderungen und alle `Meldeintervall`
Sekunden.
- Laufende Vorgaben werden vom Manager standardmaessig erneuert.
- Nach `VorgabeTimeout` ist eine nicht erneuerte Vorgabe ungueltig.
- Nach einem Neustart wird keine alte Vorgabe ungeprueft aufgenommen.
@@ -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,74 @@
# ADR 0002: Verbraucher 1-Stufig arbeitet ereignisbasiert
## Status
Akzeptiert, am 17. September 2026 um getrennte Mindestzeiten erweitert.
## Kontext
Das Enelix-1-Modul berechnete seinen Zustand in einem festen Intervall und
bildete Lastwechselsperren ueber `Interval`, `IdleCounterMax` und weitere
zyklusabhaengige Zaehler ab. Ein erster Enelix-2-Stand ersetzte dies durch
einen allgemeinen `Umschaltabstand`. Dieser konnte unterschiedliche
Geraeteanforderungen fuer Ein- und Aus-Zustand nicht ausdruecken.
Bei einem asynchron schaltenden Geraet kann ausserdem der Aktorbefehl nicht als
physische Zustandsbestaetigung gelten.
## Entscheidung
Der `VerbraucherEinStufig` verwendet keine zyklische Regelberechnung.
Schaltkontakt, Rueckmeldung, Manager-Vorgabe, Freigabe, Vorgabeablauf und
Tagesplanung loesen die Regelung direkt aus.
Der allgemeine `Umschaltabstand` entfaellt. An seine Stelle treten:
- `Mindesteinschaltdauer` in Sekunden, Standard 5,
- `Mindestausschaltdauer` in Sekunden, Standard 5.
Die passende Mindestzeit beginnt mit dem bestaetigten Zustandswechsel. Ohne
separate Rueckmeldung bestaetigt die Aktorvariable den Wechsel unmittelbar.
Mit separater Rueckmeldung beginnt die Mindestzeit erst, wenn deren Wert den
Zielzustand erreicht.
Bis dahin bleiben Istleistung und Leistungsangebot beim rueckgemeldeten
Zustand. `SchaltbefehlAusstehend=true`,
`AenderungMoeglich=false` und `Schaltbereit=false` machen die laufende
Umschaltung fuer den Manager sichtbar.
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. Bei separater Rueckmeldung zaehlt ausschliesslich der bestaetigte
Ein-Zustand.
## Alternativen
- Ein fester Regelzyklus wurde verworfen, weil Reaktionszeit und Zeitregeln
wieder voneinander abhaengen wuerden.
- Ein allgemeiner Umschaltabstand wurde verworfen, weil Ein- und Aus-Zustand
unterschiedliche Mindestzeiten benoetigen koennen.
- Der Aktorbefehl als sofortige physische Bestaetigung wurde bei vorhandener
Rueckmeldung verworfen.
- Ein zusaetzlicher Rueckmelde-Timeout wurde nicht eingefuehrt. Ein
ausstehender Befehl bleibt transparent sichtbar, bis ein neues Ereignis den
Zustand klaert.
## Folgen
- Lastwechsel reagieren ohne Polling auf relevante Ereignisse.
- `Interval`, `IdleCounterMax` und `Umschaltabstand` entfallen.
- Waerend Mindestzeiten und ausstehenden Rueckmeldungen wird nur die
bestaetigte Istleistung angeboten.
- Der Manager erhaelt Status und Restmindestzeit als Zustandseintraege.
- Eine unerwartete Abweichung zwischen Aktor und Rueckmeldung ausserhalb eines
laufenden Schaltvorgangs wird als Stoerung gemeldet.
- Tageslaufzeit und Mindestzeiten sind unabhaengig von einer Zyklusdauer.
- Das Modul bleibt vollstaendig im Repository Enelix EMS.
## Offene Punkte
- Eine spaetere sperrbare Variante wird separat spezifiziert.
- Ein konfigurierbarer Rueckmelde-Timeout benoetigt eine eigene Entscheidung.
@@ -0,0 +1,43 @@
# ADR 0003: Standardisierte Teststrategie
## Kontext
Die Module besitzen PHPUnit-Tests, ihre Prüfungen in einer echten
IP-Symcon-Installation waren jedoch unterschiedlich aufgebaut. Dadurch fehlten
ein einheitlicher Aufruf, sicherer Cleanup, maschinenlesbare Berichte und eine
verbindliche Regel für neue Module.
## Entscheidung
Enelix verwendet zwei Testebenen. PHPUnit bleibt die schnelle Pflichtprüfung bei
jedem Push. Zusätzlich erhält jedes Modul einen registrierten Symcon-Modultest
mit dem gemeinsamen `TestContext`. Der Runner unterstützt `single`,
`affected` und `all`, isoliert jeden Testlauf und liefert Konsole, JSON und
JUnit XML.
Der Manager-Test erzeugt jeden implementierten Verbrauchertyp und prüft ihn
einzeln sowie in einer gemeinsamen Konstellation. Neue Verbrauchertypen müssen
diese Matrix erweitern.
## Alternativen
- Nur PHPUnit: verworfen, weil das reale Objektmodell, Actions und
Modulinteraktionen nicht abgedeckt werden.
- Freie Schnellausführungs-Skripte pro Modul: verworfen, weil Aufbau, Cleanup
und Berichte erneut auseinanderlaufen würden.
- Ein drittes Test-Repository: vorerst verworfen, weil Tests zusammen mit dem
jeweiligen Modul versioniert und atomar geändert werden sollen.
## Folgen
Jedes neue Modul benötigt zusätzlich zu Unit-Tests einen Manifest-Eintrag und
einen Symcon-Test. Der Vertrags-Unit-Test verhindert unregistrierte Module.
Integrationstests benötigen einen isolierten IP-Symcon-8.x-Runner. Testobjekte
dürfen ausschließlich innerhalb der vom Framework erzeugten Kategorie liegen.
## Offene Punkte
- Bereitstellung und Registrierung des Gitea-Runners mit Label `symcon-8`.
- Festlegung der Aufbewahrungsdauer für JSON- und JUnit-Artefakte.
- Erweiterung der Manager-Matrix, sobald weitere Verbrauchertypen umgesetzt
werden.
@@ -0,0 +1,76 @@
# ADR 0004: Betriebsartabhaengige Leistungsangebote
## Kontext
Der Nachrichtenvertrag 3.0 uebermittelte vom Manager nur
`Sollleistung_W`. Verbraucher meldeten ein einziges Leistungsangebot und
kannten die aktuelle Betriebsart nicht. Damit konnten Module keine
unterschiedlichen PowerSteps fuer PV- und Peakbetrieb bereitstellen.
Eine einfache Ergaenzung des Sollwertpakets reicht nicht aus: Beim Wechsel der
Betriebsart besitzt der Manager zunaechst noch das Angebot der vorherigen
Betriebsart. Eine sofortige Verteilung koennte deshalb einen Sollwert erzeugen,
den der Verbraucher im neuen Modus ablehnen muss.
## Entscheidung
Der Vertrag wird inkompatibel auf Version `4.0` angehoben.
- Managerdaten und Verbraucherdaten enthalten verpflichtend `Betriebsart`
mit dem Wert `PV` oder `Peak`.
- `Sollleistung_W` in Managerdaten darf `null` sein.
- `null` ist eine reine Betriebsart-Ankuendigung und kein Schaltbefehl.
- Der Verbraucher uebernimmt die Betriebsart, berechnet sein Leistungsangebot
neu und meldet dieses mit derselben Betriebsart zurueck.
- Der Manager verwendet nur Angebote seiner aktuellen Betriebsart.
- Solange mindestens ein aktiver Verbraucher nicht synchronisiert ist, erfolgt
keine Verteilung.
- Nach einem Betriebsartwechsel verwirft der Manager seinen Sollwertcache.
Die konkrete Differenz zwischen PV- und Peakangebot bleibt Verantwortung des
Verbrauchermoduls. Die derzeit implementierten Verbraucher uebernehmen die
zustandsabhaengigen Enelix-1-Angebote gezielt:
- Der einstufige Verbraucher bietet in Peak normalerweise `[0]`. Bei faelliger
Tagesmindestlaufzeit ist konfigurierbar, ob `[0, Nennleistung]` angeboten
oder nur die Nennleistung erzwungen wird.
- Die Ladestation bietet in Peak mit Solarladen `[0]`, ohne Solarladen dagegen
`[0, ...Ladestufen]` an.
- Der Warmwassererwaermer bietet unter seiner wirksamen Mindesttemperatur auch
in Peak `[0, ...Leistungsstufen]` an.
- Der Pufferspeicher bietet im Peakbetrieb unabhaengig vom Zustand `[0]` an.
- Technische Schaltsperren und ausstehende Rueckmeldungen duerfen das Angebot
weiterhin auf die aktuell gehaltene Leistung begrenzen.
## Alternativen
### Zwei Angebote gleichzeitig melden
Separate Felder fuer PV- und Peak-PowerSteps wuerden den Umschalt-Handshake
vermeiden. Sie verdoppeln jedoch alle Angebotsdaten und zwingen jedes Modul,
beide Zustaende jederzeit parallel zu berechnen.
### Betriebsart ohne Synchronisation senden
Der Manager koennte Betriebsart und Sollleistung in einem Paket senden. Das
erste Kommando nach einem Wechsel waere dann aus dem alten Angebot berechnet
und koennte ungueltig sein.
### Betriebsart aus einer Manager-Variable lesen
Eine direkte Objektkopplung wuerde die definierte Schnittstelle umgehen,
mehrere Manager erschweren und Verbraucher unnoetig an die Managerinstanz
binden.
## Folgen
- Alle Manager und Verbraucher einer Installation muessen gemeinsam auf
Vertrag 4.0 aktualisiert werden.
- Version 3.0 und 4.0 koennen nicht innerhalb derselben Managerzuordnung
gemischt werden.
- Ein Betriebsartwechsel benoetigt mindestens einen zusaetzlichen
Nachrichtenumlauf.
- Tests pruefen Vertragsvalidierung, Synchronisation und modulspezifische
Angebote fuer PV und Peak.
- ADR 0001 bleibt als historische Entscheidung erhalten, wird fuer den
aktuellen Vertrag aber durch diese Entscheidung abgeloest.
+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.
+61
View File
@@ -0,0 +1,61 @@
# Migration von Verbraucher_1_Stufig
Das Enelix-1-Modul wird nicht in-place aktualisiert. Das neue Modul verwendet
eine neue Modul-ID, Vertrag `3.0` und eine ereignisbasierte Regelung.
## Eigenschaften
| Enelix 1 / bisheriges Enelix 2 | Aktuelles 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` | Alten Minutenwert fuer gleiches Verhalten mit 60 multiplizieren. |
| `Zeit_Zwischen_Zustandswechseln` | `Mindesteinschaltdauer`, `Mindestausschaltdauer` | Alte Minuten nicht ungeprueft uebernehmen; beide Sekundenwerte geraetespezifisch setzen. |
| `Umschaltabstand` aus fruehem Enelix 2 | `Mindesteinschaltdauer`, `Mindestausschaltdauer` | Der alte Wert wird nicht automatisch uebertragen; beide Werte haben Standard 5 s. |
| `Interval` | entfaellt | Keine zyklische Regelberechnung. |
| `IdleCounterMax` | entfaellt | Keine zyklusbasierte Idle-Erkennung. |
## Variablen und Verhalten
| Alt | Aktuell |
| --- | --- |
| `DailyOnTime` | `Tageslaufzeit` in bestaetigten echten Sekunden |
| `IstNacht` | entfaellt; Tagesmindestlaufzeit wird spaetestmoeglich 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 |
Bei konfigurierter Rueckmeldung bestimmt nicht mehr der ausgegebene
Aktorbefehl, sondern erst der rueckgemeldete Zustand die Istleistung, die
Tageslaufzeit und den Start der jeweiligen Mindestzeit.
## Inbetriebnahme
1. Bestehende Instanzkonfiguration und Aktorlogik dokumentieren.
2. Neue Instanz **Verbraucher 1-Stufig** anlegen oder die bestehende
Enelix-2-Instanz aktualisieren.
3. Leistung, Aktor, Invertierung und optional Rueckmeldung konfigurieren.
4. Tagesmindestlaufzeit gegebenenfalls von Minuten in Sekunden umrechnen.
5. Mindest-Ein- und Mindest-Aus-Zeit getrennt anhand der Geraeteanforderungen
setzen.
6. Neue Instanz im Enelix-2-Manager aktiv zuordnen.
7. Ein- und Ausschalten zuerst ohne reale Last oder unter Aufsicht pruefen.
8. Bei separater Rueckmeldung kontrollieren, dass
`SchaltbefehlAusstehend` erst mit deren Zustandswechsel verschwindet.
9. Mindestens je einen verhinderten Ein- und Ausschaltvorgang testen.
10. Tageslaufzeit und 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,63 @@
# Ladestation Stand-Alone
> Status: Implementiert. Eigenstaendiges Verbrauchermodul mit direkter
> Geraeteanbindung; keine Beziehung zur Ladestation Gateway.
## Unterstuetzte Geraete
| Geraetetyp | Status | Steuerung |
| --- | --- | --- |
| go-e Charger, alte API | `GET /mqtt?payload=` | `alw` und `amp` |
| go-e Charger Gemini / Gemini flex | `GET /api/status` | `frc` und `amp` |
| smart-me Pico | Pico-Charging-API | Load-Management-Current-API mit Basic Auth |
Die Routen, Leistungsfaktoren und Erkennung wurden gezielt aus Enelix 1
uebernommen. Zugangsdaten werden weder als Variable noch im Diagnoseprotokoll
ausgegeben.
## Verhalten
- `FahrzeugVerbunden` entspricht bei go-e `car != 1` und bei Pico `State != 1`.
- Die Phasenerkennung verwendet wie Enelix 1 die gemessene Ladeleistung:
ueber 7500 W sind dreiphasig, sonst einphasig.
- Ein bereits verbundenes Fahrzeug gilt bei einem ermittelten Maximalstrom
unter 6 A als geladen.
- PV mit Solarladen bietet `0 W` und alle Ladestufen von 6 A bis zum
konfigurierten Maximum an.
- PV ohne Solarladen bietet ausschliesslich die Maximalleistung an.
- Peak mit Solarladen bietet nur `[0]` an und stoppt die Ladestation.
- Peak ohne Solarladen bietet `[0, ...Ladestufen]` an, damit der Manager die
Ladeleistung wie in Enelix 1 stufenweise reduzieren kann.
- Ohne gueltige Manager-Vorgabe wird bei Solarladen mit 0 A und ohne
Solarladen mit maximaler Leistung gefahren.
## Variablen
| Ident | Typ / Zugriff | Beschreibung |
| --- | --- | --- |
| `FahrzeugVerbunden` | Boolean / Anzeige | Geraet meldet ein verbundenes Fahrzeug. |
| `FahrzeugGeladen` | Boolean / Anzeige | Enelix-1-Erkennung unter 6 A. |
| `Fahrzeugstatus` | Integer / Anzeige | `0` unbekannt, `1` getrennt, `2` bereit, `3` laedt, `4` geladen. |
| `Ladestrom` | Float / Anzeige | Aus der gemessenen Leistung ermittelter Strom in A. |
| `Phasenzahl` | Integer / Anzeige | `0` unbekannt, `1` einphasig, `3` dreiphasig. |
| `Ladefreigabe` | Boolean / bedienbar | Nur bei `EinstellungenInVisu`; lokale Ladeerlaubnis. |
| `Solarladen` | Boolean / bedienbar | Nur bei `EinstellungenInVisu`; variable Ladestufen ein/aus. |
## Properties
Neben den gemeinsamen Verbraucher-Properties werden Geraetetyp, Adresse,
Pico-ID, Seriennummer, Zugangsdaten, maximaler Ladestrom, Abfrageintervall
sowie die Startwerte fuer Ladefreigabe und Solarladen konfiguriert.
Die gemeinsamen Verbraucher- und Diagnosevariablen entsprechen der
EMS-Schnittstelle Version 4.0. `LoggingEin` steuert ausschliesslich das
laufende Debug-Protokoll.
## Tests
Die Adaptertests verwenden einen injizierten Fake-HTTP-Transport. Dadurch
werden fuer alle drei Geraetevarianten Statusantworten, URL, HTTP-Methode,
Authentisierung und Steueraufrufe geprueft, ohne ein reales Geraet anzusprechen.
Der Symcon-Funktionstest nutzt den internen, nicht im Konfigurationsformular
sichtbaren `Testmodus` und prueft zusaetzlich Fahrzeug-/Phasenerkennung,
die vier PV-/Peak-Angebote sowie die Umschaltung ueber den `Solarladen`-Button.
+124
View File
@@ -0,0 +1,124 @@
# 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. |
| `Lizenzcode` | String | Im Enelix-Lizenzportal erworbener Aktivierungscode. |
| `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 Lizenzierung steht an erster Stelle des Formulars. Der Manager erzeugt dabei
einmalig eine UUIDv4 als stabile Installations-ID. Mit "Lizenz pruefen und binden"
wird der eingegebene Code ueber
`POST https://license.enelix.ch/api/v1/licenses/activate` an diese Installation
gebunden. Es werden weder Portal-Cookies noch ein CSRF-Token oder ein noch nicht
implementierter Lizenzendpunkt verwendet.
## 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.
- Ohne gueltige Manager-Berechtigung bleibt der Manager mit Status `203` gesperrt.
- Die Serverantwort wird lokal gespeichert und ab `refreshAfter` erneuert. Bei
einem Verbindungsfehler gilt eine zuvor bestaetigte Entwicklungsfreigabe bis
`offlineUntil`; danach wird die Regelung wieder gesperrt.
- `manager_standard` erlaubt die Grundregelung. Ein aktiver Lastspitzenmodus
benoetigt `manager_peak`.
- Der Lizenzcode wird nie geloggt. Lokal wird fuer den Lease-Abgleich nur sein
SHA-256-Wert gespeichert.
- 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 und Störüberwachung festlegen.
- Produktive signierte Lizenz-Leases nach Abschluss des Entwicklungsvertrags integrieren.
- Verhalten und Messabgrenzung bei Untermanagern im Anlagentest bestätigen.
+144
View File
@@ -0,0 +1,144 @@
# Verbraucher Pufferspeicher
> Status: implementiert fuer IP-Symcon 8.0+ und den Enelix-2-Vertrag `4.0`.
Das Modul bindet elektrische Heizstufen eines Pufferspeichers an den
Enelix-Manager an. Die Freigabe wird aus Puffer- und Aussentemperatur,
Heizkurve, Hysterese, lokaler Aktivierung und aktueller Betriebsart berechnet.
Es ist eine gezielte Adaption des Enelix-1-Moduls `Puffer_Speicher`.
Leistungsstufen, Temperaturquellen, optionale PT1-Glaettung und Heizkurve
wurden uebernommen und ueberarbeitet. Zyklische Altlogik, alte
Kommunikationsvariablen, `Idle` und `PowerSteps` als Symcon-Variable wurden
verworfen.
| Technisches Merkmal | Wert |
| --- | --- |
| Modulname | `VerbraucherPufferspeicher` |
| Alias | `Pufferspeicher` |
| Modul-ID | `{C92D5EEF-9632-47A5-9659-4B02BF40FBE9}` |
| Enelix-Vertrag | `4.0` |
## Regelverhalten
Die Solltemperatur wird berechnet als:
`FusspunktVorlauftemperatur + HeizkurvenSteigung * (20 - Aussentemperatur)`
Anschliessend wird sie auf `HeizkurveMinimaltemperatur` und
`HeizkurveMaximaltemperatur` begrenzt.
Die Einschaltschwelle wird je nach `MindesttemperaturModus` bestimmt:
- `Aus`: Solltemperatur minus Hysterese,
- `Statisch`: konfigurierte absolute Mindesttemperatur,
- `Differenz`: Solltemperatur minus konfigurierte Differenz.
Es gilt:
- im PV-Betrieb unter der Einschaltschwelle: `[0, ...Leistungsstufen]`,
- im PV-Betrieb an oder oberhalb der Einschaltschwelle: `[0]`,
- im Peakbetrieb unabhaengig von der Temperatur: `[0]`,
- bei `Aktiv=false`: immer `[0]`,
- bei ungueltigen Temperaturen: `[0]` und nicht verfuegbar.
Der Pufferspeicher ist im Peakbetrieb damit pauschal gesperrt. Beim Wechsel in
Peak wird eine aktive Stufe sicher ausgeschaltet.
## Properties
Die Properties erscheinen im Formular in dieser Reihenfolge.
### Manager und Zeitverhalten
| Ident | Standard | Beschreibung |
| --- | ---: | --- |
| `PrioritaetPV` | `0` | Prioritaet im PV-Betrieb |
| `PrioritaetPeak` | `0` | Prioritaet im Peakbetrieb |
| `Meldeintervall` | `10 s` | Vollstaendige Rueckmeldung |
| `VorgabeTimeout` | `120 s` | Gueltigkeit einer Vorgabe |
| `LastwechselSperrzeit` | `5 s` | Mindestzeit zwischen Lastwechseln |
### Pufferspeicher
| Ident | Standard | Beschreibung |
| --- | ---: | --- |
| `LeistungsStufen` | `[]` | Stufe, Leistung und Boolean-Schaltkontakt |
| `PufferfuehlerVariableID` | `0` | Integer- oder Floatvariable |
| `AussentemperaturVariableID` | `0` | Integer- oder Floatvariable |
### Heizkurve
| Ident | Standard | Beschreibung |
| --- | ---: | --- |
| `FusspunktVorlauftemperatur` | `35.0 C` | Vorlauf bei 20 C aussen |
| `HeizkurvenSteigung` | `1.0` | Anhebung je Kelvin fallender Aussentemperatur |
| `HeizkurveMinimaltemperatur` | `20.0 C` | Untere Sollwertbegrenzung |
| `HeizkurveMaximaltemperatur` | `80.0 C` | Obere Sollwertbegrenzung |
| `Hysterese` | `5.0 K` | Abstand zur Einschaltschwelle |
| `MindesttemperaturModus` | `Aus` | Aus, statisch oder Differenz |
| `Mindesttemperatur` | `20.0 C` | Wert im statischen Modus |
| `MindesttemperaturDifferenz` | `5.0 K` | Wert im Differenzmodus |
| `WaermepumpenSolltemperaturVariableID` | `0` | Optionale Quelle fuer die WP-Uebernahme |
Der Button **Wert von Waermepumpe uebernehmen** berechnet den Fusspunkt so,
dass die lokale Heizkurve beim aktuellen Aussenwert durch den aktuellen
WP-Sollwert verlaeuft. Es entsteht keine dauerhafte Laufzeitkopplung.
### Erweiterte Einstellungen
| Ident | Standard | Beschreibung |
| --- | ---: | --- |
| `TemperaturMaxAlter` | `120 s` | Maximal zulaessiges Alter beider Messwerte |
| `PuffertemperaturGlaetten` | `false` | Aktiviert PT1-Glaettung |
| `ZeitKonstante` | `120 s` | PT1-Zeitkonstante |
| `EinstellungenInVisu` | `false` | Gemeinsame Verbraucheroption |
| `DiagnosevariablenAnzeigen` | `false` | Diagnosevariablen einblenden |
| `LoggingEin` | `false` | Debug-Protokoll aktivieren |
## Variablen
Immer vorhanden:
| Ident | Typ | Beschreibung |
| --- | --- | --- |
| `Aktiv` | Boolean / bedienbar | Lokale Ein-/Aus-Freigabe |
| `Puffertemperatur` | Float | Aktueller, gegebenenfalls geglaetteter Wert |
| `Aussentemperatur` | Float | Heizkurveneingang |
| `Solltemperatur` | Float | Berechneter und begrenzter Sollwert |
| `Heizbedarf` | Boolean | Temperatur liegt unter der Einschaltschwelle |
Optional sichtbar sind die gemeinsamen Verbraucher-Diagnosevariablen sowie
`AktiveStufe`, `BezogeneEnergie` und `TemperaturenGueltig`.
## Managerkommunikation
Das Modul implementiert `VerbraucherSchnittstelle` und Vertrag `4.0`.
Es uebernimmt zuerst die vom Manager angekuendigte Betriebsart, berechnet das
passende Leistungsangebot und meldet es mit derselben Betriebsart zurueck.
Erst danach wird eine Sollleistung angenommen.
Die Rueckmeldung enthaelt zusaetzlich:
- `Puffertemperatur_C`,
- `Aussentemperatur_C`,
- `Solltemperatur_C`,
- `Heizbedarf`,
- `AktiveStufe`,
- `Fuehlerfehler` und `Schaltfehler`.
## Sicherheit und Schaltung
Die Kontakte werden Break-before-make geschaltet: zuerst alle aus, danach
hoechstens eine Stufe ein. Ein Schaltfehler fuehrt zu einem Ausschaltversuch
aller Kontakte und einer Stoerungsmeldung. Hardwareseitige Verriegelungen,
Temperaturbegrenzer und Schutzorgane bleiben erforderlich.
## Tests
- `PufferspeicherReglerTest` prueft Heizkurve, Hysterese, Ein/Aus, Peak und
alle Mindesttemperatur-Modi.
- `PufferspeicherModulstrukturTest` prueft Metadaten, Properties, Formular,
Vertrag und Schaltfolge.
- Der standardisierte Symcon-Test prueft die reale Modulinstanz und ist im
gemeinsamen Manifest sowie in der Manager-Matrix registriert.
+27
View File
@@ -0,0 +1,27 @@
# EMS-Module und Modulentwürfe
> Manager, Warmwassererwaermer, Verbraucher 1-Stufig und Ladestation
> Stand-Alone 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 (implementiert) |
| [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.
+220
View File
@@ -0,0 +1,220 @@
# Verbraucher 1-Stufig
> Status: implementiert fuer IP-Symcon 8 und den Enelix-2-Vertrag `4.0`.
Das Modul schaltet einen elektrischen Ein-/Aus-Verbraucher. Im frei
schaltbaren Zustand meldet es bei PV `[0, Nennleistung]` und bei Peak `[0]`.
Ist die Tagesmindestlaufzeit faellig, wird bei PV die Nennleistung erzwungen.
Bei Peak ist konfigurierbar, ob `[0, Nennleistung]` angeboten oder die
Nennleistung ohne Sperrmoeglichkeit erzwungen wird. Waerend einer
Mindest-Ein- oder Mindest-Aus-Zeit sowie bei einer ausstehenden Rueckmeldung
wird unabhaengig von der Betriebsart nur der aktuelle bestaetigte
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 der zustandsabhaengigen Mindestzeit,
- das Ablaufen einer Manager-Vorgabe,
- den naechsten relevanten Zeitpunkt der Tageslaufzeitplanung.
Es gibt keinen Regelzyklus, kein `Interval`, keinen `IdleCounterMax` und
keinen allgemeinen `Umschaltabstand`. `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 neun
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 physisch eingeschaltet. |
| `Mindestlaufzeit` | Integer | `0` | Geforderte Laufzeit pro lokalem Kalendertag in Sekunden, maximal 86400. |
| `PeakSperreBeiMindestlaufzeitAnbieten` | Boolean | `true` | Bietet bei faelliger Tagesmindestlaufzeit im Peakbetrieb zusaetzlich `0` als Sperre an. Bei `false` wird die Nennleistung lokal erzwungen. |
| `Mindesteinschaltdauer` | Integer | `5` | Mindestzeit in Sekunden, die ein bestaetigter Ein-Zustand gehalten wird. |
| `Mindestausschaltdauer` | Integer | `5` | Mindestzeit in Sekunden, die ein bestaetigter Aus-Zustand gehalten wird. |
| `DiagnosevariablenAnzeigen` | Boolean | `false` | Legt gemeinsame und modulspezifische Diagnosevariablen an. |
Beide Mindestzeiten duerfen `0` sein. Die lokale Aktion `Aktiv=false`
bleibt der einzige bewusste Override und schaltet sicher aus, auch wenn die
Mindesteinschaltdauer noch laeuft.
`EinstellungenInVisu` ist Teil der gemeinsamen Verbraucherbasis. Das Modul
besitzt derzeit keine zusaetzlichen bedienbaren Einstellvariablen und wertet
diese Property deshalb nicht weiter aus.
## Variablen
Immer sichtbar:
| Ident | Typ / Zugriff | Beschreibung |
| --- | --- | --- |
| `Aktiv` | Boolean / bedienbar | Lokale EMS-Freigabe; Startwert `false`. |
| `Schaltzustand` | Boolean / Anzeige | Bestaetigter oder aus dem Aktor berechneter Zustand. |
| `Tageslaufzeit` | Integer / Anzeige | Bestaetigte Laufzeit des lokalen Kalendertags in Sekunden. |
Mit `DiagnosevariablenAnzeigen` werden zusaetzlich angelegt:
- `Istleistung`
- `Leistungsquelle`
- `Sollleistung`
- `SollwertGueltig`
- `Verfuegbar`
- `AenderungMoeglich`
- `Stoerung`
- `Stoertext`
- `Rueckmeldefehler`
- `SchaltbefehlAusstehend`
- `RestMindestzeit`
Die Regellogik arbeitet mit persistenten Attributen und ist nicht von der
Sichtbarkeit der Diagnosevariablen abhaengig.
## Mindest-Ein- und Mindest-Aus-Zeit
Nach einer bestaetigten Einschaltung wird fuer
`Mindesteinschaltdauer` Sekunden kein regulaeres Ausschalten angeboten. Nach
einer bestaetigten Ausschaltung blockiert `Mindestausschaltdauer` ein
regulaeres Einschalten.
Waerend einer Mindestzeit gilt:
- `AenderungMoeglich=false`,
- `Schaltbereit=false`,
- `RestMindestzeit_s` enthaelt die verbleibenden Sekunden,
- `Leistungswerte_W` enthaelt nur die bestaetigte Istleistung.
Ein einmaliger Timer loest am Ende der Mindestzeit genau eine Neuberechnung
aus. Der fruehere allgemeine `Umschaltabstand` ist vollstaendig entfallen.
## Rueckmeldung und Schaltbereitschaft
Ohne separate Rueckmeldung wird der logische Zustand der Aktorvariable als
sofortige Schaltbestaetigung verwendet.
Mit `RueckmeldungVariableID` ist ausschliesslich deren Booleanwert fuer
`Schaltzustand`, `Istleistung_W`, Tageslaufzeit und den Beginn der
Mindestzeit massgeblich. Nach einem Aktorbefehl und vor der passenden
Rueckmeldung meldet das Modul:
- die bisherige bestaetigte Istleistung,
- `SchaltbefehlAusstehend=true`,
- `Schaltbereit=false`,
- `AenderungMoeglich=false`,
- nur den bisherigen Leistungswert in `Leistungswerte_W`.
Sobald die Rueckmeldung den Zielzustand bestaetigt, beginnt die passende
Mindest-Ein- oder Mindest-Aus-Zeit. Es gibt bewusst keinen zusaetzlichen
zyklischen Rueckmelde-Timeout. Bleibt die Rueckmeldung aus, bleibt der
Schaltbefehl sichtbar ausstehend.
Weichen Aktor- und Rueckmeldezustand ohne ausstehenden Schaltbefehl voneinander
ab, wird `Rueckmeldefehler=true`, `Verfuegbar=false` und eine Stoerung
gemeldet.
## Tagesmindestlaufzeit
Die Tageslaufzeit wird mit der in IP-Symcon eingestellten lokalen Zeitzone
gefuehrt. Sommer- und Winterzeit sowie der Wechsel um Mitternacht werden
beruecksichtigt. Nur der bestaetigte Ein-Zustand zaehlt.
Ist die konfigurierte Tagesmindestlaufzeit noch nicht erreicht, wird der
Verbraucher zum spaetestmoeglichen Zeitpunkt angefordert, an dem die
Restlaufzeit bis Mitternacht noch erfuellt werden kann. Im PV-Betrieb wird die
Nennleistung dann lokal erzwungen. Im Peakbetrieb meldet das Modul bei
aktivierter Property `PeakSperreBeiMindestlaufzeitAnbieten` wie Enelix 1
`[0, Nennleistung]`, sodass der Manager zwischen Sperren und Nachholen der
Laufzeit waehlen kann. Ist die Property deaktiviert, wird auch im Peakbetrieb
nur die Nennleistung angeboten und lokal erzwungen. Eine noch aktive
Mindestausschaltdauer hat Vorrang; eine unmoegliche Restlaufzeit kann nicht
rueckwirkend nachgeholt werden.
## Managerkommunikation
Das Modul implementiert
`VerbraucherSchnittstelle::ManagerdatenEmpfangen()` und verwendet
ausschliesslich Vertrag `4.0`. Der Verbraucher besitzt keine
Manager-ID-Property. Er akzeptiert nur Manager, in deren manueller oder
automatischer Verbraucherzuordnung seine Instanz aktiv eingetragen ist.
Neben den gemeinsamen Vertragsfeldern werden diese Zustaende gemeldet:
- `Schaltzustand`
- `SchaltbefehlAusstehend`
- `Schaltbereit`
- `RestMindestzeit_s`
- `Tageslaufzeit_s`
- `Rueckmeldefehler`
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 und bei Bedarf `SchaltkontaktInvertiert` aktivieren.
5. Optional eine separate Boolean-Rueckmeldung auswaehlen. Dort muss
`true` dem physisch eingeschalteten Verbraucher entsprechen.
6. Tagesmindestlaufzeit, Mindest-Einschaltdauer und Mindest-Ausschaltdauer
passend zum angeschlossenen Geraet festlegen.
7. Die 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.
- Eine Ein-Vorgabe setzt zuerst den Aktor; mit separater Rueckmeldung bleibt
`SchaltbefehlAusstehend=true`, bis diese einschaltet.
- Die Mindesteinschaltdauer beginnt erst mit der Ein-Rueckmeldung.
- Vor Ablauf der Mindesteinschaltdauer wird kein Ausschalten angeboten.
- Nach bestaetigter Ausschaltung beginnt die Mindestausschaltdauer.
- Vor Ablauf der Mindestausschaltdauer wird kein Einschalten angeboten.
- `RestMindestzeit_s` erreicht 0 und `Schaltbereit` wird danach true.
- `Tageslaufzeit` steigt nur bei bestaetigtem Ein-Zustand.
- Ein unerwarteter Unterschied zwischen Aktor und Rueckmeldung erzeugt eine
Stoerung.
- Nach einem Neustart wird keine alte Manager-Vorgabe ungeprueft fortgesetzt.
## Tests
Nur dieses Modul:
```bash
composer check:verbraucher-einstufig
```
Gesamtes Repository inklusive dieser Modulsuite:
```bash
composer check
```
Die separate Suite und ihre Struktur sind unter
[`tests/VerbraucherEinStufig`](../../../tests/VerbraucherEinStufig/README.md)
dokumentiert.
## Migration
Die Zuordnung der Enelix-1- und bisherigen Enelix-2-Zeitwerte 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.
+406
View File
@@ -0,0 +1,406 @@
# Verbraucher Warmwassererwaermer
> Status: implementiert fuer IP-Symcon 8.0+ und den Enelix-2-Vertrag `4.0`.
Das Modul bindet einen elektrischen Warmwasserspeicher mit einer oder mehreren
exklusiven Leistungsstufen an den Enelix-Manager an. Der Manager kann nur eine
der aktuell angebotenen Leistungen vorgeben. Mindesttemperatur, Hysterese,
Zeitplan und Legionellenfunktion bestimmen dabei zustands- und
betriebsartabhaengig das Leistungsangebot.
Das Modul ist eine gezielte Adaption des Enelix-1-Moduls
`Boiler_x_Stufig`. Zyklische Altlogik und alte Kommunikationsvariablen wurden
nicht uebernommen. Die Regelung arbeitet ereignisbasiert und verwendet
ausschliesslich den Enelix-2-Nachrichtenvertrag.
| Technisches Merkmal | Wert |
| --- | --- |
| Modulname | `VerbraucherWarmwassererwaermer` |
| Alias | `Wassererwärmer` |
| Modul-ID | `{B7C54AF4-AD7D-4FE4-B75D-203693906251}` |
| Modultyp | Geraeteinstanz (`3`) |
| Funktionspraefix | `ENELIX` |
| Enelix-Vertrag | `4.0` |
## Funktionsumfang
- beliebig viele, eindeutig konfigurierte Leistungsstufen inklusive `0 W`,
- exklusive Break-before-make-Schaltung der Stufenkontakte,
- lokale Mindest- und Maximaltemperatur mit Hysterese,
- optionale PT1-Glaettung des Temperaturmesswerts,
- thermische Vorhersage fuer taegliche Solltemperatur-Zeitpunkte,
- zweistufige Legionellenfunktion mit Fruehest- und Spaetestintervall,
- berechnete Istleistung, aktive Stufe und bezogene Energie,
- ereignisbasierte Regelung mit einstellbarer Lastwechselsperre,
- Kommunikation mit manuell oder automatisch zugeordneten Enelix-Managern,
- optionale Diagnosevariablen und Debug-Logging.
## Voraussetzungen
- IP-Symcon ab Version 8.0,
- installierte Bibliothek `Enelix-EMS` vom Branch `develop`,
- eine Integer- oder Floatvariable als Temperaturfuehler,
- mindestens eine positive Leistungsstufe,
- je Leistungsstufe eine eigene Booleanvariable mit funktionsfaehiger Aktion,
- ein Enelix-Manager, dem die Verbraucherinstanz aktiv zugeordnet wird.
Die Schaltkontakte duerfen nicht mehrfach verwendet werden. Der Anlagenaufbau
muss sicherstellen, dass die konfigurierten Leistungsstufen elektrisch
zulaessig sind. Die Software ersetzt keine hardwareseitigen Verriegelungen,
Temperaturbegrenzer oder Schutzorgane.
## Regelungsablauf
Eine Neuberechnung wird insbesondere ausgeloest durch:
- einen neuen oder aktualisierten Temperaturmesswert,
- das Ein- oder Ausschalten von `Aktiv`,
- eine neue Manager-Vorgabe,
- eine Aenderung der bedienbaren Temperatursollwerte,
- das Ende der Lastwechselsperre,
- das Uebernehmen einer neuen Instanzkonfiguration,
- eine periodische Vollmeldung an den Manager.
`Meldeintervall` ist kein Regelintervall. Es stellt die regelmaessige
vollstaendige Rueckmeldung sicher. Die Lastwechselsperre verwendet einen
einmaligen Timer und loest nach ihrem Ablauf genau eine Neuberechnung aus.
Die Zielentscheidung folgt der Enelix-1-Zustandsmatrix:
1. Bei deaktivierter Instanz oder ungueltiger Temperatur ist der Verbraucher
nicht verfuegbar und wird ausgeschaltet. Eine ungueltige Konfiguration wird
bereits beim Uebernehmen mit Status `202` abgewiesen.
2. Unter der wirksamen Mindesttemperatur sowie in der unteren Hysterese bei
aktiver Stufe gilt: PV erzwingt die Maximalleistung, Peak bietet
`[0, ...Leistungsstufen]` an.
3. Ausserhalb dieses Mindesttemperaturbereichs bietet Peak nur `[0]` an.
4. Im PV-Betrieb werden unterhalb von `Maximaltemperatur - Hysterese` alle
Stufen angeboten. In der oberen Hysterese gilt dies nur, solange bereits
eine Stufe aktiv ist.
5. An oder oberhalb der wirksamen Maximaltemperatur wird ausgeschaltet.
Eine erzwungene PV-Maximalleistung wird mit `AenderungMoeglich=false`
gemeldet. Das Peak-Array bleibt dagegen durch den Manager waehlbar.
## Konfiguration
Das Modul besitzt 19 Properties: sechs gemeinsame Verbraucher-Properties und
13 modulspezifische Properties.
### Manager und Zeitverhalten
| Ident | Typ | Standard | Beschreibung |
| --- | --- | ---: | --- |
| `PrioritaetPV` | Integer | `0` | Prioritaet im PV-Betrieb; kleinere Werte werden zuerst beruecksichtigt. |
| `PrioritaetPeak` | Integer | `0` | Prioritaet im Peak-Betrieb; kleinere Werte werden zuerst beruecksichtigt. |
| `Meldeintervall` | Integer | `10` | Abstand der vollstaendigen Verbraucherrueckmeldungen in Sekunden. |
| `VorgabeTimeout` | Integer | `120` | Zeit in Sekunden, nach der eine nicht erneuerte Manager-Vorgabe ungueltig wird. |
| `LastwechselSperrzeit` | Integer | `5` | Mindestzeit zwischen zwei Lastwechseln in Sekunden. |
### Speichereinstellungen
| Ident | Typ | Standard | Beschreibung |
| --- | --- | ---: | --- |
| `LeistungsStufen` | JSON-Liste | `[]` | Positive Leistung, Stufennummer und Boolean-Schaltkontakt jeder Stufe. |
| `Boilerfuehler_PT1` | Integer | `0` | Objekt-ID der Integer- oder Floatvariable fuer die Speichertemperatur. |
| `LegionellenfunktionAktiv` | Boolean | `true` | Aktiviert die periodische Anhebung auf Legionellentemperatur. |
| `LegionellenMinimalintervallTage` | Integer | `4` | Fruehestens: Legionellentemperatur wird zur Maximaltemperatur. |
| `LegionellenMaximalintervallTage` | Integer | `7` | Spaetestens: Legionellentemperatur wird zur Mindesttemperatur. |
Jeder Eintrag in `LeistungsStufen` besteht aus:
| Feld | Anforderung |
| --- | --- |
| `Stufe` | Positive Ganzzahl. |
| `Leistung` | Eindeutige positive Ganzzahl in Watt. |
| `Schaltkontakt_Stufe` | Eindeutige Objekt-ID einer Booleanvariable. |
Die Eintraege werden intern nach Leistung sortiert. `0 W` wird automatisch als
Aus-Zustand in das Leistungsangebot aufgenommen und darf nicht als eigene
Stufe konfiguriert werden.
### Erweiterte Speichereinstellungen
| Ident | Typ | Standard | Beschreibung |
| --- | --- | ---: | --- |
| `Boilervolumen` | Integer | `300` | Speichervolumen in Litern fuer die thermische Zeitplanprognose. |
| `Hysterese` | Float | `5.0` | Temperaturhysterese in Kelvin. |
| `Zeitplan` | JSON-Liste | `[]` | Taegliche Zielwerte mit `Uhrzeit` im Format `HH:MM` und `Solltemperatur`. |
Der jeweils naechste Zeitplaneintrag wird fuer heute oder den folgenden Tag
ermittelt. Reicht die verbleibende Zeit bei maximaler elektrischer Leistung
rechnerisch nicht mehr aus, wird das Zeitplanziel zur wirksamen
Mindesttemperatur. Im PV-Betrieb wird dann die hoechste Stufe erzwungen, im
Peakbetrieb werden alle Stufen angeboten. Die Berechnung verwendet Wasser mit
`4186 J/(kg K)` und beruecksichtigt keine Speicher- oder Leitungsverluste. Fuer
Datum und Uhrzeit gilt die in IP-Symcon eingestellte lokale Zeitzone.
### Erweiterte sonstige Einstellungen
| Ident | Typ | Standard | Beschreibung |
| --- | --- | ---: | --- |
| `TemperaturMaxAlter` | Integer | `30` | Maximal zulaessiges Alter des Temperaturmesswerts in Sekunden. |
| `BoilertemperaturGlaetten` | Boolean | `false` | Aktiviert die PT1-Glaettung. |
| `ZeitKonstante` | Integer | `120` | PT1-Zeitkonstante in Sekunden. |
| `EinstellungenInVisu` | Boolean | `false` | Zeigt Temperatursollwerte an und gibt ihre Bedienung frei. |
| `DiagnosevariablenAnzeigen` | Boolean | `false` | Legt die 14 Diagnosevariablen an. |
| `LoggingEin` | Boolean | `false` | Aktiviert laufende Debug-Ausgaben des Moduls. |
Alle Zeitwerte und Intervalle muessen groesser als `0` sein. Die beiden
Prioritaeten muessen mindestens `0` betragen. Das Legionellen-Minimalintervall
darf nicht groesser als das Maximalintervall sein.
## Temperaturverarbeitung
Der Rohwert des konfigurierten Fuehlers muss numerisch und juenger als
`TemperaturMaxAlter` sein. Andernfalls werden `TemperaturGueltig=false`,
`Verfuegbar=false` und eine Stoerung gemeldet; eine aktive Stufe wird
ausgeschaltet.
Ohne Glaettung entspricht `Boilertemperatur` dem letzten gueltigen Rohwert.
Mit aktivierter Glaettung verwendet das Modul ein PT1-Glied. Der erste Wert
initialisiert den Filter; danach wird die tatsaechlich seit der letzten
Temperaturberechnung vergangene Zeit verwendet.
Die Solltemperaturen werden bei der ersten Initialisierung auf folgende Werte
gesetzt:
| Variable | Initialwert |
| --- | ---: |
| `Mindesttemperatur` | `45.0 Grad C` |
| `Maximaltemperatur` | `60.0 Grad C` |
| `Legionellentemperatur` | `65.0 Grad C` |
Es gilt immer:
`Mindesttemperatur < Maximaltemperatur <= Legionellentemperatur`
Temperaturwerte muessen zwischen `0` und `100 Grad C` liegen. Bedienaktionen sind
nur erlaubt, wenn `EinstellungenInVisu=true` gesetzt ist.
## Hysterese und lokales Nachladen
Sinkt die Temperatur unter die wirksame Mindesttemperatur, erzwingt PV die
hoechste Leistungsstufe; Peak bietet dagegen Aus und alle Stufen an. In der
unteren Hysterese bis `Mindesttemperatur + Hysterese` bleibt dieses Verhalten
nur erhalten, solange bereits eine Stufe aktiv ist.
Im PV-Betrieb werden unter `Maximaltemperatur - Hysterese` alle Stufen
angeboten. Innerhalb der oberen Hysterese bleibt das Angebot nur bei einer
bereits aktiven Stufe erhalten. An der Maximaltemperatur wird ausgeschaltet.
## Legionellenfunktion
Die Legionellenfunktion arbeitet zweistufig:
1. Ab `LegionellenMinimalintervallTage` wird die Legionellentemperatur zur
wirksamen Maximaltemperatur. Der Manager kann die Aufheizung innerhalb des
verbleibenden Zeitfensters ermoeglichen.
2. Ab `LegionellenMaximalintervallTage` wird die Legionellentemperatur auch zur
wirksamen Mindesttemperatur. PV erzwingt dadurch die Maximalleistung; Peak
bietet das vollstaendige Stufenarray bis zum Ziel an.
Erreicht die gueltige Speichertemperatur die Legionellentemperatur, wird der
Zeitpunkt als erfolgreicher Abschluss gespeichert und das Intervall beginnt
neu. `LegioCounter` zeigt die Sekunden seit diesem Abschluss.
`Legionellentemperatur` wird nur als Symcon-Variable angelegt, wenn
`LegionellenfunktionAktiv=true` ist. Beim Ausschalten der Funktion wird die
Variable geloescht; der letzte Wert bleibt intern erhalten und steht bei einem
spaeteren Wiedereinschalten wieder zur Verfuegung.
## Lastwechselsperre und Schaltsicherheit
Nach jedem tatsaechlichen Lastwechsel wird fuer
`LastwechselSperrzeit` Sekunden kein weiterer regulaerer Wechsel zugelassen.
In dieser Zeit gilt:
- `AenderungMoeglich=false`,
- `Leistungswerte_W` enthaelt nur die aktuell gehaltene Leistung,
- eine abweichende Manager-Vorgabe wird abgewiesen,
- ein einmaliger Timer plant die Freigabe.
Ein sicherheitsbedingtes Ausschalten, beispielsweise bei ungueltiger
Temperatur oder erreichter Maximaltemperatur, darf die Sperre uebergehen.
Separate Variablen `Idle` oder `IdleCounter` existieren nicht.
Beim Schalten werden zuerst alle konfigurierten Kontakte ausgeschaltet und
anschliessend hoechstens der Kontakt der Zielstufe eingeschaltet. Schlaegt ein
Schaltvorgang fehl, versucht das Modul alle Kontakte auszuschalten, setzt die
berechnete Istleistung auf `0 W` und meldet einen Schaltfehler.
## Variablen
### Betriebs- und Einstellvariablen
| Ident | Typ / Zugriff | Sichtbarkeit | Beschreibung |
| --- | --- | --- | --- |
| `Aktiv` | Boolean / bedienbar | immer | Lokale EMS-Freigabe; Startwert `false`. |
| `Boilertemperatur` | Float / Anzeige | immer | Letzter gueltiger, gegebenenfalls geglaetteter Temperaturwert. |
| `Mindesttemperatur` | Float / bedienbar | bei `EinstellungenInVisu` | Untere Grenze fuer lokales Nachladen. |
| `Maximaltemperatur` | Float / bedienbar | bei `EinstellungenInVisu` | Abschaltgrenze im Normalbetrieb. |
| `Legionellentemperatur` | Float / bedienbar | Funktion aktiv; Anzeige freigegeben | Ziel des Legionellenprogramms. |
### Diagnosevariablen
Mit `DiagnosevariablenAnzeigen=true` werden diese 14 Variablen angelegt. Beim
Abschalten der Property werden sie wieder geloescht. Die Regellogik verwendet
persistente interne Zustaende und funktioniert unabhaengig von diesen
Anzeigevariablen.
| Ident | Typ | Bedeutung |
| --- | --- | --- |
| `Istleistung` | Float | Berechnete aktuelle Leistung in Watt. |
| `Leistungsquelle` | Integer | Immer `1` fuer berechnete Leistung. |
| `Sollleistung` | Integer | Aktuell angenommene oder lokal erzwungene Zielvorgabe in Watt. |
| `SollwertGueltig` | Boolean | Eine Manager-Vorgabe ist vorhanden und noch nicht abgelaufen. |
| `Verfuegbar` | Boolean | Das Modul kann grundsaetzlich durch das EMS gesteuert werden. |
| `AenderungMoeglich` | Boolean | Der Manager darf momentan eine neue Leistung vorgeben. |
| `Stoerung` | Boolean | Mindestens eine Stoerbedingung ist aktiv. |
| `Stoertext` | String | Zusammengefasste lesbare Stoerbeschreibung. |
| `AktiveStufe` | Integer | Stufennummer der berechneten aktuellen Leistung; `0` bedeutet aus. |
| `BezogeneEnergie` | Float | Aus Istleistung und verstrichener Zeit berechnete Energie in kWh. |
| `TemperaturGueltig` | Boolean | Temperaturfuehler vorhanden, numerisch und nicht veraltet. |
| `NachladenAktiv` | Boolean | Lokales Nachladen aufgrund der Mindesttemperatur ist aktiv. |
| `LegionellenbetriebAktiv` | Boolean | Das Fruehestintervall der Legionellenfunktion ist erreicht. |
| `LegioCounter` | Integer | Sekunden seit dem letzten erfolgreichen Legionellenabschluss. |
## Managerkommunikation
Das Modul implementiert
`VerbraucherSchnittstelle::ManagerdatenEmpfangen()` und verwendet den
Enelix-2-Vertrag `4.0`. Es besitzt keine Manager-ID-Property. Zugelassen sind
nur Manager, in deren manueller oder automatischer Verbraucherzuordnung die
Instanz aktiv eingetragen ist.
Eine Manager-Vorgabe wird nur angenommen, wenn:
- Vertragsversion, Absender, Empfaenger und Datentypen gueltig sind,
- der Manager die Instanz aktiv zugeordnet hat,
- `Sollleistung_W` im zuletzt berechneten `Leistungswerte_W` enthalten ist.
Rueckmeldungen erfolgen bei relevanten Ereignissen, kurz verzoegert nach einer
Manager-Vorgabe und zusaetzlich alle `Meldeintervall` Sekunden. Die kurze
Verzoegerung verhindert eine synchrone Manager-Verbraucher-Endlosschleife.
Die Verbraucherrueckmeldung enthaelt insbesondere:
- Prioritaeten fuer PV- und Peak-Betrieb,
- `Leistungswerte_W`, `AenderungMoeglich` und `Verfuegbar`,
- `Istleistung_W` mit `Leistungsquelle=1`,
- Zustandseintraege fuer Sollleistung, Wassertemperatur, Maximaltemperatur,
aktive Stufe, Nachladen, Legionellenbetrieb und Stoerungen.
Nach `VorgabeTimeout` Sekunden ohne Erneuerung wird eine Manager-Vorgabe
bei der naechsten Neuberechnung, spaetestens bei der folgenden Vollmeldung,
ungueltig. Beim Neustart wird keine alte Manager-Vorgabe ungeprueft wieder
aufgenommen.
## Abgrenzung des aktuellen Stands
- `Istleistung`, `AktiveStufe` und `BezogeneEnergie` sind berechnete Werte; das
Modul besitzt keinen Anschluss fuer einen elektrischen Leistungsmesser.
- Die Stufenkontakte sind Aktoren, keine separate physische Rueckmeldung. Ihr
Booleanzustand wird bei einer Regelberechnung auf Plausibilitaet geprueft.
- Externe Aenderungen an den Stufenkontakten loesen selbst keine
Neuberechnung aus. Der Temperaturfuehler ist die abonnierte Messvariable.
- Der Zeitplan beschreibt taeglich wiederkehrende Zielzeitpunkte und keine
einmaligen Kalendertermine.
## Status- und Fehlerzustaende
| Status | Bedeutung | Typische Ursache |
| ---: | --- | --- |
| `102` | Aktiv | Konfiguration und Temperaturmessung sind gueltig. |
| `201` | Temperaturmessung ungueltig | Fuehler fehlt, ist nicht numerisch oder zu alt. |
| `202` | Konfiguration ungueltig | Unzulaessige Intervalle, Temperaturen, Stufen, Kontakte oder Zeitplaneintraege. |
| `203` | Schaltfehler | Eine Aktion eines Stufenkontakts ist fehlgeschlagen. |
Eine Uebertemperatur liegt vor, wenn `Boilertemperatur` die hoehere Grenze aus
Maximal- und Legionellentemperatur um mehr als `Hysterese` ueberschreitet. Sie
wird ueber `Stoerung` und `Stoertext` gemeldet; der Modulstatus bleibt ohne
zusaetzlichen Schalt- oder Fuehlerfehler `102`.
## Installation und Inbetriebnahme
1. Im IP-Symcon Module Control den Branch `develop` der Bibliothek
`https://git.belevo.ch/ENELIX/Enelix-EMS.git` installieren oder
aktualisieren.
2. Unter **Instanz hinzufuegen** nach dem Alias **Wassererwärmer** oder dem
Modulnamen `VerbraucherWarmwassererwaermer` suchen und eine Instanz anlegen.
3. Den Temperaturfuehler auswaehlen und `TemperaturMaxAlter` passend zum
Aktualisierungsintervall des Fuehlers einstellen.
4. Alle Leistungsstufen mit positiver Leistung und jeweils eigenem
Boolean-Schaltkontakt konfigurieren.
5. Normal-, Legionellen- und Zeitplanwerte pruefen. Danach die Konfiguration
uebernehmen.
6. Die Instanz im Enelix-Manager manuell aktiv zuordnen oder bei automatischer
Suche in der gefundenen Liste aktivieren.
7. Fuer die Erstpruefung `DiagnosevariablenAnzeigen` und bei Bedarf
`LoggingEin` einschalten.
8. Unter Aufsicht `Aktiv` einschalten und jede Stufe einzeln pruefen. Dabei
kontrollieren, dass nie zwei Stufenkontakte gleichzeitig aktiv sind.
9. Eine Manager-Vorgabe fuer `0 W` und fuer jede konfigurierte Stufe pruefen.
10. Diagnosevariablen nach der Abnahme bei Bedarf wieder ausschalten.
## Abnahmecheckliste
- Die Instanz erreicht Status `102`.
- `Aktiv=false` schaltet alle Stufenkontakte aus.
- Der Fuehlerwert erscheint unverfaelscht oder erwartungsgemaess geglaettet in
`Boilertemperatur`.
- Unterhalb der Mindesttemperatur wird die hoechste Stufe lokal erzwungen.
- Oberhalb der Maximaltemperatur werden alle Stufen ausgeschaltet.
- Jede Manager-Vorgabe schaltet genau den zugeordneten Kontakt.
- Direkt nach einem Wechsel ist `AenderungMoeglich=false`; nach der
konfigurierten Sperrzeit wird ein regulaerer Wechsel wieder freigegeben.
- Eine Gegenanforderung waehrend der Sperre liegt nicht im gemeldeten
Leistungsangebot.
- Ein veralteter oder ungueltiger Fuehlerwert fuehrt zu Status `201`, einer
Stoerung und sicherem Ausschalten.
- Bei aktiver Legionellenfunktion wird `Legionellentemperatur` angelegt; nach
dem Abschalten der Funktion wird die Variable geloescht.
- Mit ausgeschalteten Diagnosevariablen bleiben Regelung und Kommunikation
funktionsfaehig.
## Diagnosehinweise
- Status `202`: zuerst Fuehler-ID, Leistungsstufen, eindeutige Kontakte,
Temperaturreihenfolge, Intervalle und Zeitplanformat pruefen.
- Status `201`: `VariableUpdated`, Datentyp des Fuehlers und
`TemperaturMaxAlter` kontrollieren.
- Status `203`: Aktionen der Boolean-Schaltkontakte einzeln in IP-Symcon
testen; das Modul versucht bei einem Fehler alle Kontakte auszuschalten.
- Manager-Vorgabe wird abgewiesen: Zuordnung im Manager,
`AenderungMoeglich` und das aktuelle `Leistungswerte_W` pruefen.
- Unerwartetes Nachladen: Mindesttemperatur, Hysterese, naechstes Zeitplanziel
und Alter des letzten Legionellenabschlusses kontrollieren.
## Tests
`WarmwasserReglerTest.php` prueft die reine Regellogik fuer Leistungsstufen,
PT1, Lastwechselsperre, Energieberechnung, thermische Prognose, Zeitplan und
Legionellengrenzen.
`WarmwassererwaermerModulstrukturTest.php` prueft Metadaten, Formular, alle 19
Properties, ereignisbasierte Lastwechselsperre, Vertrag `4.0`, bedarfsgesteuerte
Diagnosevariablen, Legionellentemperatur, Temperatursollwerte und
Break-before-make-Schaltung.
## Migration von Enelix 1
Die Zuordnung der uebernommenen, angepassten und entfallenen Felder des alten
Moduls `Boiler_x_Stufig` ist in
[`docs/migration/Boiler-x-Stufig.md`](../../migration/Boiler-x-Stufig.md)
dokumentiert.
Wesentliche Unterschiede:
- `Interval` entfaellt; die Regelung ist ereignisbasiert.
- `IdleCounterMax` wird durch `LastwechselSperrzeit` in Sekunden ersetzt.
- `Idle` und `IdleCounter` entfallen vollstaendig.
- `PowerSteps` wird durch das Vertragsfeld `Leistungswerte_W` ersetzt.
- Prioritaeten sind Properties; Betriebsart und Leistungsverteilung liegen im
Manager.
- Die neue Modul-ID erfordert eine neue Instanz; eine automatische Umwandlung
des Enelix-1-Objekts findet nicht statt.
+104
View File
@@ -0,0 +1,104 @@
# Standardisierte Tests
## Ziel
Dieses Repository verwendet zwei verbindliche Testebenen:
1. PHPUnit prüft reine PHP-Logik und Struktur bei jedem Push.
2. Symcon-Modultests prüfen reale Instanzen, Variablen, Actions und Zusammenspiel
in IP-Symcon 8.x.
Alle implementierten Module müssen in `tests/Symcon/manifest.php` eingetragen
sein und ein eigenes Skript in `tests/Symcon/modules/` besitzen. Der
PHPUnit-Test `SymconTestContractTest` erzwingt diese Regel auch für künftig
hinzugefügte Module.
## Testvertrag
Ein Modultest gibt eine aufrufbare Funktion mit dieser Signatur zurück:
```php
use Belevo\EnelixEMS\SymconTest\TestContext;
return static function (TestContext $test): void {
$test->runCase('Beschreibung', static function (TestContext $test): void {
// Instanz aufbauen, Eingang simulieren und Ergebnis prüfen.
});
};
```
Das Framework erzeugt für jeden Modultest eine eindeutige Kategorie unterhalb
der Objektwurzel. Instanzen und Hilfsobjekte werden ausschließlich dort
angelegt. Der Runner entfernt den vollständigen Baum in einem `finally`-Pfad.
Ein fehlgeschlagener Cleanup macht den Gesamtlauf rot.
Tests dürfen keine vorhandenen Objekte verändern oder anhand ihres Namens
löschen. Globale Variablenprofile müssen nur dann registriert und entfernt
werden, wenn sie vor dem Lauf nicht existierten.
## Modi
- `all`: alle registrierten Module
- `single`: genau die als Auswahl übergebenen Module
- `affected`: die durch geänderte Pfade ermittelten Module
Verfügbare Module: `LadestationStandAlone`, `Manager`, `VerbraucherEinStufig`, `Warmwassererwaermer`.
Der Manager-Test enthält Manager ohne Verbraucher, jeden Verbrauchertyp einzeln und alle aktuell implementierten Verbrauchertypen gemeinsam.
## Manuelle Ausführung in IP-Symcon
Das Repository muss über die Modulverwaltung installiert und auf dem zu
prüfenden Stand sein. In der Schnellausführung:
```php
require_once IPS_GetKernelDir() . 'modules/Enelix-EMS/tests/Symcon/bootstrap.php';
$result = enelixEmsRunSymconTests('all');
echo $result['console'];
```
Ein Einzeltest wird beispielsweise mit
`enelixEmsRunSymconTests('single', 'Manager')` gestartet.
Auf dem Agent-Server kann derselbe Lauf über JSON-RPC ausgeführt werden:
```bash
tests/Symcon/bin/run-symcon-tests.sh all
tests/Symcon/bin/run-symcon-tests.sh single Manager
```
Die URL kann ausschließlich zur Laufzeit über `ENELIX_SYMCON_URL` gesetzt
werden. Zugangsdaten gehören nicht in Repository, Skripte oder Logs.
## Berichte
Jeder Lauf erzeugt:
- eine kurze Konsolenzusammenfassung,
- `build/symcon-tests/report.json` für Diagnose und Archivierung,
- `build/symcon-tests/junit.xml` für CI-Auswertung.
Zusätzlich schreibt der Runner die Zusammenfassung in das IP-Symcon-Log.
## CI-Regeln
Bei jedem Push laufen sämtliche PHPUnit-Tests. Auf `develop` werden zusätzlich
die von den geänderten Pfaden betroffenen Symcon-Modultests ausgeführt. Bei
gemeinsamen Framework- oder Vertragsänderungen läuft die vollständige Suite.
Für `beta` ist immer `all` vorgeschrieben.
Die Symcon-Jobs benötigen einen geschützten Runner mit dem Label `symcon-8`,
lokalem Zugriff auf eine isolierte IP-Symcon-8.x-Instanz und den installierten
Repository-Stand. Ohne diesen Runner ist die Richtlinie dokumentiert, aber
nicht technisch durchsetzbar.
## Checkliste für neue Module
1. PHPUnit-Tests für die reine Logik ergänzen.
2. `tests/Symcon/modules/<Modul>.php` hinzufügen.
3. Modul und betroffene Pfade in `tests/Symcon/manifest.php` registrieren.
4. Instanz, Pflichtvariablen, Actions, Normalfall und mindestens einen
Fehler- oder Grenzfall prüfen.
5. Alle Hilfsobjekte über `TestContext` anlegen.
6. `composer check`, Einzeltest und Gesamttest erfolgreich ausführen.
+64
View File
@@ -0,0 +1,64 @@
# Enelix-2-Demoanlage
Die Demo erzeugt idempotent eine vollständige, spielbare EMS-Anlage in IP-Symcon 8.x:
- Enelix Manager mit manueller Zuordnung,
- zwei mehrstufige Boiler (1,2/2,4 kW und 1,5/3,0 kW),
- einen einstufigen Entfeuchter (0,9 kW),
- simulierte PV-Erzeugung, Grundlast, Netzleistung und Boilertemperaturen,
- native Symcon-Instanz `Energy Distribution` für den Energiefluss,
- eigene Kachelansichten mit allen Laufzeitparametern je Boiler,
- kombinierten Verlauf für PV-Leistung, Hausverbrauch und Netzleistung,
- kombinierten Verlauf der kumulierten PV-, Haus-, Bezugs- und Einspeiseenergie,
- Archivierung der Leistungs-, Energie- und Temperaturwerte.
## Installation
Das Repository muss in der IP-Symcon-Modulverwaltung auf dem gewünschten Stand installiert sein.
Danach in der Schnellausführung starten:
```php
require_once IPS_GetKernelDir() . 'modules/Enelix-EMS/examples/Demoanlage/bootstrap.php';
$result = enelixDemoInstall();
print_r($result);
```
Ein weiterer Lauf aktualisiert dieselbe Anlage anhand stabiler Idents und erzeugt keine Duplikate.
Bestehende, nicht zur Demo gehörende Objekte werden nicht verändert oder gelöscht.
## Bedienung
In der Kachelvisualisierung öffnet die Startkategorie `Enelix 2 Demoanlage`.
Unter `Simulation` lassen sich Tageslauf, Tageszeit, Geschwindigkeit,
PV-Spitzenleistung, Bewölkung und Haus-Grundlast verändern. Der Manager verteilt
den verfügbaren PV-Überschuss nach Priorität auf beide Boiler und den Entfeuchter.
`Übersicht` zeigt die native Symcon-Energieverteilung. Die Kacheln `Boiler 1`
und `Boiler 2` öffnen direkt sämtliche Bedien-, Status- und Diagnosevariablen,
ohne zuerst das Instanz-Konfigurationsformular zu zeigen. Unter `Leistung und
Energie` liegen die beiden kombinierten Zeitdiagramme und die Energiezähler.
Die Energiesummen verwenden die beschleunigte Simulationszeit.
## Validierung
- Alle vier Enelix-Instanzen müssen Status 102 besitzen.
- Der Manager muss drei Verbraucher melden.
- Bei ausreichendem PV-Überschuss müssen Verbraucher schalten.
- Netzleistung, PV-Leistung, Hausverbrauch, Verbraucherleistungen, Energiesummen und Temperaturen werden archiviert.
- Die native Energieverteilung muss sechs Knoten enthalten.
- Beide Diagrammkacheln müssen mindestens drei beschriftete Zeitreihen anzeigen.
- Ein erneuter Installationslauf muss dieselbe Root-ID zurückgeben.
## Rollback
Nur die eindeutig gekennzeichnete Demo wird entfernt:
```php
require_once IPS_GetKernelDir() . 'modules/Enelix-EMS/examples/Demoanlage/bootstrap.php';
enelixDemoRemove(true);
```
Der boolesche Parameter ist eine bewusste Löschbestätigung. Fremde Objektbäume
und Systeminstanzen bleiben unberührt.
+913
View File
@@ -0,0 +1,913 @@
<?php
declare(strict_types=1);
const ENELIX_DEMO_ROOT_IDENT = 'ENELIX_DEMO_2';
const ENELIX_DEMO_MANAGER_MODULE = '{6F771B18-59D4-4C8A-B951-3B2FE9F6A2C4}';
const ENELIX_DEMO_BOILER_MODULE = '{B7C54AF4-AD7D-4FE4-B75D-203693906251}';
const ENELIX_DEMO_SINGLE_MODULE = '{15879A4E-D0C2-4495-83DE-46E1E462591E}';
const ENELIX_DEMO_DUMMY_MODULE = '{485D0419-BE97-4548-AA9C-C083EB82E61E}';
const ENELIX_DEMO_TILE_MODULE = '{B5B875BB-9B76-45FD-4E67-2607E45B3AC4}';
const ENELIX_DEMO_ARCHIVE_MODULE = '{43192F0B-135B-4CE7-A0A7-1475603F3060}';
const ENELIX_DEMO_ENERGY_DISTRIBUTION_MODULE = '{BA90FDB3-C203-8F26-B4D6-F3DB99905724}';
function enelixDemoInstall(): array
{
enelixDemoValidateRuntime();
$root = enelixDemoEnsureCategory(0, ENELIX_DEMO_ROOT_IDENT, 'Enelix 2 Demoanlage', 100);
IPS_SetInfo($root, 'Idempotent erzeugte Enelix-2-Demo. Entfernen nur mit enelixDemoRemove(true).');
$dashboard = enelixDemoEnsureCategory($root, 'Dashboard', 'Übersicht', 10);
$simulation = enelixDemoEnsureCategory($root, 'Simulation', 'Simulation', 20);
$boiler1View = enelixDemoEnsureCategory($root, 'Boiler1Ansicht', 'Boiler 1', 30);
$boiler2View = enelixDemoEnsureCategory($root, 'Boiler2Ansicht', 'Boiler 2', 40);
$history = enelixDemoEnsureCategory($root, 'Historie', 'Leistung und Energie', 50);
$ems = enelixDemoEnsureCategory($root, 'EMS', 'Manager und Verbraucher', 60);
$technical = enelixDemoEnsureCategory($root, 'Technik', 'Simulationstechnik', 90, true);
enelixDemoEnsureProfiles();
$legacyFlow = enelixDemoEnsureVariable($dashboard, 'Energiefluss', 'Energiefluss (Legacy)', 3, '~HTMLBox', '', 90);
IPS_SetHidden($legacyFlow, true);
$pvDisplay = enelixDemoEnsureVariable($dashboard, 'PVLeistung', 'PV-Leistung', 2, 'ENELIX.Demo.PowerPositive', 0.0, 20);
$houseDisplay = enelixDemoEnsureVariable($dashboard, 'Hausverbrauch', 'Hausverbrauch gesamt', 2, 'ENELIX.Demo.PowerPositive', 0.0, 30);
$loadDisplay = enelixDemoEnsureVariable($dashboard, 'Verbraucherleistung', 'Geregelte Verbraucher', 2, 'ENELIX.Demo.PowerPositive', 0.0, 40);
$grid = enelixDemoEnsureVariable($dashboard, 'Netzleistung', 'Netzleistung', 2, 'ENELIX.Demo.Power', 0.0, 50);
$boiler1Power = enelixDemoEnsureVariable($dashboard, 'Boiler1Leistung', 'Boiler 1 Leistung', 2, 'ENELIX.Demo.PowerPositive', 0.0, 60);
$boiler2Power = enelixDemoEnsureVariable($dashboard, 'Boiler2Leistung', 'Boiler 2 Leistung', 2, 'ENELIX.Demo.PowerPositive', 0.0, 70);
$dryerPower = enelixDemoEnsureVariable($dashboard, 'EntfeuchterLeistung', 'Entfeuchter Leistung', 2, 'ENELIX.Demo.PowerPositive', 0.0, 80);
$powerChart = enelixDemoEnsureVariable($history, 'Leistungsdiagramm', 'Leistungsverlauf (Legacy)', 3, '~HTMLBox', '', 90);
$energyChart = enelixDemoEnsureVariable($history, 'Energiediagramm', 'Kumulierte Energien (Legacy)', 3, '~HTMLBox', '', 100);
IPS_SetHidden($powerChart, true);
IPS_SetHidden($energyChart, true);
$pvEnergy = enelixDemoEnsureVariable($history, 'PVEnergie', 'PV-Energie', 2, '~Electricity', 0.0, 30);
$houseEnergy = enelixDemoEnsureVariable($history, 'HausEnergie', 'Hausverbrauch Energie', 2, '~Electricity', 0.0, 40);
$gridImportEnergy = enelixDemoEnsureVariable($history, 'NetzbezugEnergie', 'Netzbezug Energie', 2, '~Electricity', 0.0, 50);
$gridExportEnergy = enelixDemoEnsureVariable($history, 'EinspeisungEnergie', 'Einspeisung Energie', 2, '~Electricity', 0.0, 60);
$auto = enelixDemoEnsureVariable($simulation, 'Automatik', 'Tageslauf automatisch', 0, '~Switch', true, 10);
$hour = enelixDemoEnsureVariable($simulation, 'Tageszeit', 'Simulierte Tageszeit', 2, 'ENELIX.Demo.Hour', 8.0, 20);
$speed = enelixDemoEnsureVariable($simulation, 'Geschwindigkeit', 'Minuten je Schritt', 1, 'ENELIX.Demo.Speed', 10, 30);
$peak = enelixDemoEnsureVariable($simulation, 'PVSpitze', 'PV-Spitzenleistung', 2, 'ENELIX.Demo.PowerPositive', 7000.0, 40);
$cloud = enelixDemoEnsureVariable($simulation, 'Bewoelkung', 'Bewölkung', 1, 'ENELIX.Demo.Percent', 20, 50);
$base = enelixDemoEnsureVariable($simulation, 'Grundlast', 'Haus-Grundlast', 2, 'ENELIX.Demo.PowerPositive', 900.0, 60);
$actorScript = enelixDemoEnsureScript(
$technical,
'AktorAktion',
'Simulierte Aktoren',
"<?php\nSetValue((int) \$_IPS['VARIABLE'], (bool) \$_IPS['VALUE']);\n",
10
);
$tickScript = enelixDemoEnsureScript(
$technical,
'SimulationTick',
'Simulation aktualisieren',
"<?php\nrequire_once IPS_GetKernelDir() . 'modules/Enelix-EMS/examples/Demoanlage/bootstrap.php';\nenelixDemoTick('Timer');\n",
20
);
$controlScript = enelixDemoEnsureScript(
$technical,
'BedienAktion',
'Bedienwerte übernehmen',
"<?php\nSetValue((int) \$_IPS['VARIABLE'], \$_IPS['VALUE']);\nIPS_RunScript(" . $tickScript . ");\n",
30
);
foreach ([$auto, $hour, $speed, $peak, $cloud, $base] as $variableId) {
IPS_SetVariableCustomAction($variableId, $controlScript);
}
enelixDemoEnsureCyclicEvent($tickScript, 'Simulationszyklus', 2);
$temp1 = enelixDemoEnsureVariable($technical, 'Boiler1Temperatur', 'Boiler 1 Temperatur', 2, '~Temperature', 49.0, 100);
$temp2 = enelixDemoEnsureVariable($technical, 'Boiler2Temperatur', 'Boiler 2 Temperatur', 2, '~Temperature', 47.0, 110);
$boiler11 = enelixDemoEnsureVariable($technical, 'Boiler1Stufe1', 'Boiler 1 Stufe 1', 0, '~Switch', false, 120);
$boiler12 = enelixDemoEnsureVariable($technical, 'Boiler1Stufe2', 'Boiler 1 Stufe 2', 0, '~Switch', false, 130);
$boiler21 = enelixDemoEnsureVariable($technical, 'Boiler2Stufe1', 'Boiler 2 Stufe 1', 0, '~Switch', false, 140);
$boiler22 = enelixDemoEnsureVariable($technical, 'Boiler2Stufe2', 'Boiler 2 Stufe 2', 0, '~Switch', false, 150);
$dryContact = enelixDemoEnsureVariable($technical, 'EntfeuchterKontakt', 'Entfeuchter Kontakt', 0, '~Switch', false, 160);
$chartUpdated = enelixDemoEnsureVariable($technical, 'LetztesDiagrammUpdate', 'Letztes Diagramm-Update', 1, '', 0, 170);
IPS_SetHidden($chartUpdated, true);
$flowPv = enelixDemoEnsureVariable($technical, 'EnergieflussPVkW', 'PV-Leistung', 2, '~Power', 0.0, 300);
$flowGrid = enelixDemoEnsureVariable($technical, 'EnergieflussNetzkW', 'Netzleistung', 2, '~Power', 0.0, 310);
$flowBase = enelixDemoEnsureVariable($technical, 'EnergieflussGrundlastkW', 'Haus-Grundlast', 2, '~Power', 0.0, 320);
$flowBoiler1 = enelixDemoEnsureVariable($technical, 'EnergieflussBoiler1kW', 'Boiler 1', 2, '~Power', 0.0, 330);
$flowBoiler2 = enelixDemoEnsureVariable($technical, 'EnergieflussBoiler2kW', 'Boiler 2', 2, '~Power', 0.0, 340);
$flowDryer = enelixDemoEnsureVariable($technical, 'EnergieflussEntfeuchterkW', 'Entfeuchter', 2, '~Power', 0.0, 350);
$flowHouse = enelixDemoEnsureVariable($technical, 'DiagrammHausverbrauchkW', 'Hausverbrauch gesamt', 2, '~Power', 0.0, 360);
$flowLoads = enelixDemoEnsureVariable($technical, 'DiagrammVerbraucherkW', 'Geregelte Verbraucher', 2, '~Power', 0.0, 370);
foreach ([
$flowPv, $flowGrid, $flowBase, $flowBoiler1,
$flowBoiler2, $flowDryer, $flowHouse, $flowLoads,
] as $flowVariableId) {
IPS_SetHidden($flowVariableId, true);
}
foreach ([$boiler11, $boiler12, $boiler21, $boiler22, $dryContact] as $actorId) {
IPS_SetVariableCustomAction($actorId, $actorScript);
}
$boiler1 = enelixDemoEnsureInstance($ems, 'Boiler1', 'Boiler 1 · 300 Liter', ENELIX_DEMO_BOILER_MODULE, 20);
$boiler2 = enelixDemoEnsureInstance($ems, 'Boiler2', 'Boiler 2 · 500 Liter', ENELIX_DEMO_BOILER_MODULE, 30);
$dryer = enelixDemoEnsureInstance($ems, 'Entfeuchter', 'Entfeuchter · 1-stufig', ENELIX_DEMO_SINGLE_MODULE, 40);
enelixDemoConfigure($boiler1, [
'Boilerfuehler_PT1' => $temp1,
'LeistungsStufen' => json_encode([
['Stufe' => 1, 'Leistung' => 1200, 'Schaltkontakt_Stufe' => $boiler11],
['Stufe' => 2, 'Leistung' => 2400, 'Schaltkontakt_Stufe' => $boiler12],
], JSON_THROW_ON_ERROR),
'Boilervolumen' => 300,
'LastwechselSperrzeit' => 2,
'TemperaturMaxAlter' => 30,
'LegionellenfunktionAktiv' => false,
'PrioritaetPV' => 0,
'PrioritaetPeak' => 2,
'Meldeintervall' => 5,
'VorgabeTimeout' => 30,
'EinstellungenInVisu' => true,
'DiagnosevariablenAnzeigen' => true,
]);
enelixDemoConfigure($boiler2, [
'Boilerfuehler_PT1' => $temp2,
'LeistungsStufen' => json_encode([
['Stufe' => 1, 'Leistung' => 1500, 'Schaltkontakt_Stufe' => $boiler21],
['Stufe' => 2, 'Leistung' => 3000, 'Schaltkontakt_Stufe' => $boiler22],
], JSON_THROW_ON_ERROR),
'Boilervolumen' => 500,
'LastwechselSperrzeit' => 2,
'TemperaturMaxAlter' => 30,
'LegionellenfunktionAktiv' => false,
'PrioritaetPV' => 1,
'PrioritaetPeak' => 1,
'Meldeintervall' => 5,
'VorgabeTimeout' => 30,
'EinstellungenInVisu' => true,
'DiagnosevariablenAnzeigen' => true,
]);
enelixDemoConfigure($dryer, [
'Nennleistung' => 900,
'SchaltkontaktVariableID' => $dryContact,
'RueckmeldungVariableID' => $dryContact,
'Mindesteinschaltdauer' => 2,
'Mindestausschaltdauer' => 2,
'Mindestlaufzeit' => 0,
'PrioritaetPV' => 2,
'PrioritaetPeak' => 0,
'Meldeintervall' => 5,
'VorgabeTimeout' => 30,
'EinstellungenInVisu' => true,
'DiagnosevariablenAnzeigen' => true,
]);
$energyDistribution = enelixDemoEnsureInstance(
$dashboard,
'EnergieverteilungNativ',
'Energiefluss',
ENELIX_DEMO_ENERGY_DISTRIBUTION_MODULE,
10
);
enelixDemoConfigureEnergyDistribution($energyDistribution, [
enelixDemoEnergyNode($flowPv, 'PV-Anlage', 'solar'),
enelixDemoEnergyNode($flowGrid, 'Stromnetz', 'grid'),
enelixDemoEnergyNode($flowBase, 'Haus-Grundlast', 'consumer'),
enelixDemoEnergyNode($flowBoiler1, 'Boiler 1', 'consumer', $temp1),
enelixDemoEnergyNode($flowBoiler2, 'Boiler 2', 'consumer', $temp2),
enelixDemoEnergyNode($flowDryer, 'Entfeuchter', 'consumer'),
]);
IPS_SetHidden($energyDistribution, false);
$oldEnergyDistribution = enelixDemoFind($dashboard, 'Energieverteilung');
if ($oldEnergyDistribution > 0 && $oldEnergyDistribution !== $energyDistribution) {
IPS_SetHidden($oldEnergyDistribution, true);
}
foreach ([$boiler1, $boiler2] as $boilerId) {
IPS_RequestAction($boilerId, 'Mindesttemperatur', 42.0);
IPS_RequestAction($boilerId, 'Maximaltemperatur', 62.0);
IPS_RequestAction($boilerId, 'Aktiv', true);
}
IPS_RequestAction($dryer, 'Aktiv', true);
$manager = enelixDemoEnsureInstance($ems, 'Manager', 'Enelix Manager', ENELIX_DEMO_MANAGER_MODULE, 10);
$assignment = array_map(
static fn (int $id): array => ['InstanzID' => $id, 'Aktiv' => true],
[$boiler1, $boiler2, $dryer]
);
enelixDemoConfigure($manager, [
'NetzleistungVariableID' => $grid,
'Netzleistungsfaktor' => 1.0,
'MesswertMaxAlter' => 10,
'AutomatischeSuche' => false,
'SuchbereichID' => $root,
'VerbraucherZuordnung' => json_encode($assignment, JSON_THROW_ON_ERROR),
'KeepAlive' => 5,
'VerbraucherTimeout' => 15,
'SollwertSolarladen' => 0.0,
'Umschaltdifferenz' => 0.0,
'DiagnosevariablenAnzeigen' => true,
'LoggingEin' => false,
]);
foreach ([$boiler1, $boiler2, $dryer] as $consumerId) {
IPS_RequestAction($consumerId, 'Melden', true);
}
IPS_RequestAction($manager, 'Aktiv', true);
$boiler1List = enelixDemoEnsureInstance(
$dashboard,
'Boiler1Liste',
'Boiler 1',
ENELIX_DEMO_DUMMY_MODULE,
110
);
$boiler2List = enelixDemoEnsureInstance(
$dashboard,
'Boiler2Liste',
'Boiler 2',
ENELIX_DEMO_DUMMY_MODULE,
120
);
enelixDemoConfigure($boiler1List, []);
enelixDemoConfigure($boiler2List, []);
enelixDemoLinkInstanceVariables($boiler1List, $boiler1, $temp1);
enelixDemoLinkInstanceVariables($boiler2List, $boiler2, $temp2);
IPS_SetHidden($boiler1View, true);
IPS_SetHidden($boiler2View, true);
IPS_SetHidden($boiler1, true);
IPS_SetHidden($boiler2, true);
foreach (['Boiler1Link', 'Boiler2Link'] as $oldLinkIdent) {
$oldLink = enelixDemoFind($dashboard, $oldLinkIdent);
if ($oldLink > 0) {
IPS_SetHidden($oldLink, true);
}
}
enelixDemoEnsureLink($dashboard, 'ManagerLink', 'Manager', $manager, 100);
enelixDemoEnsureLink($dashboard, 'EntfeuchterLink', 'Entfeuchter', $dryer, 130);
enelixDemoEnsureLink($simulation, 'Boiler1TemperaturLink', 'Boiler 1 Temperatur', $temp1, 70);
enelixDemoEnsureLink($simulation, 'Boiler2TemperaturLink', 'Boiler 2 Temperatur', $temp2, 80);
enelixDemoConfigureArchive([
$pvDisplay, $houseDisplay, $loadDisplay, $grid,
$boiler1Power, $boiler2Power, $dryerPower,
$pvEnergy, $houseEnergy, $gridImportEnergy, $gridExportEnergy,
$temp1, $temp2,
$flowPv, $flowGrid, $flowBase, $flowBoiler1, $flowBoiler2, $flowDryer,
$flowHouse, $flowLoads,
]);
enelixDemoEnsureChart($history, 'LeistungsdiagrammNativ', 'Leistungsverlauf', [
enelixDemoChartDataset($flowPv, 'PV', '#f4c542', '~Power'),
enelixDemoChartDataset($flowHouse, 'Hausverbrauch', '#2bb673', '~Power'),
enelixDemoChartDataset($flowLoads, 'Geregelte Verbraucher', '#2786ac', '~Power'),
enelixDemoChartDataset($flowGrid, 'Netzleistung', '#e95d5d', '~Power'),
], 10);
enelixDemoEnsureChart($history, 'EnergiediagrammNativ', 'Kumulierte Energien', [
enelixDemoChartDataset($pvEnergy, 'PV', '#f4c542', '~Electricity'),
enelixDemoChartDataset($houseEnergy, 'Hausverbrauch', '#2bb673', '~Electricity'),
enelixDemoChartDataset($gridImportEnergy, 'Netzbezug', '#e95d5d', '~Electricity'),
enelixDemoChartDataset($gridExportEnergy, 'Einspeisung', '#38a3db', '~Electricity'),
], 20);
enelixDemoConfigureVisualization($root, $energyDistribution);
enelixDemoTick('Install');
return [
'rootId' => $root,
'visualizationId' => enelixDemoFirstInstance(ENELIX_DEMO_TILE_MODULE),
'managerId' => $manager,
'consumerIds' => [$boiler1, $boiler2, $dryer],
'tickScriptId' => $tickScript,
];
}
function enelixDemoTick(string $source = 'Manual'): void
{
$root = enelixDemoRequiredId(0, ENELIX_DEMO_ROOT_IDENT);
$dashboard = enelixDemoRequiredId($root, 'Dashboard');
$simulation = enelixDemoRequiredId($root, 'Simulation');
$ems = enelixDemoRequiredId($root, 'EMS');
$technical = enelixDemoRequiredId($root, 'Technik');
$autoId = enelixDemoRequiredId($simulation, 'Automatik');
$hourId = enelixDemoRequiredId($simulation, 'Tageszeit');
$minutes = max(1, (int) GetValue(enelixDemoRequiredId($simulation, 'Geschwindigkeit')));
$hour = (float) GetValue($hourId);
if ($source === 'Timer' && (bool) GetValue($autoId)) {
$hour = fmod($hour + ($minutes / 60.0), 24.0);
SetValue($hourId, $hour);
}
$peak = (float) GetValue(enelixDemoRequiredId($simulation, 'PVSpitze'));
$cloud = (int) GetValue(enelixDemoRequiredId($simulation, 'Bewoelkung'));
$base = (float) GetValue(enelixDemoRequiredId($simulation, 'Grundlast'));
$sun = ($hour >= 6.0 && $hour <= 18.0)
? max(0.0, sin(M_PI * ($hour - 6.0) / 12.0))
: 0.0;
$pv = $peak * $sun * (1.0 - (min(100, max(0, $cloud)) * 0.0075));
$contacts = [
'boiler11' => enelixDemoRequiredId($technical, 'Boiler1Stufe1'),
'boiler12' => enelixDemoRequiredId($technical, 'Boiler1Stufe2'),
'boiler21' => enelixDemoRequiredId($technical, 'Boiler2Stufe1'),
'boiler22' => enelixDemoRequiredId($technical, 'Boiler2Stufe2'),
'dryer' => enelixDemoRequiredId($technical, 'EntfeuchterKontakt'),
];
$powerBefore = enelixDemoControlledPower($contacts);
$temp1 = enelixDemoRequiredId($technical, 'Boiler1Temperatur');
$temp2 = enelixDemoRequiredId($technical, 'Boiler2Temperatur');
if ($source === 'Timer') {
enelixDemoUpdateTemperature($temp1, (bool) GetValue($contacts['boiler12']) ? 2400 : ((bool) GetValue($contacts['boiler11']) ? 1200 : 0), $minutes);
enelixDemoUpdateTemperature($temp2, (bool) GetValue($contacts['boiler22']) ? 3000 : ((bool) GetValue($contacts['boiler21']) ? 1500 : 0), $minutes);
}
$gridId = enelixDemoRequiredId($dashboard, 'Netzleistung');
SetValue($gridId, round($base + $powerBefore - $pv, 1));
$consumers = [
enelixDemoRequiredId($ems, 'Boiler1'),
enelixDemoRequiredId($ems, 'Boiler2'),
enelixDemoRequiredId($ems, 'Entfeuchter'),
];
foreach ($consumers as $consumerId) {
IPS_RequestAction($consumerId, 'Melden', true);
}
$manager = enelixDemoRequiredId($ems, 'Manager');
IPS_RequestAction($manager, 'Regeln', true);
$boiler1 = (bool) GetValue($contacts['boiler12']) ? 2400 : ((bool) GetValue($contacts['boiler11']) ? 1200 : 0);
$boiler2 = (bool) GetValue($contacts['boiler22']) ? 3000 : ((bool) GetValue($contacts['boiler21']) ? 1500 : 0);
$dryer = (bool) GetValue($contacts['dryer']) ? 900 : 0;
$power = $boiler1 + $boiler2 + $dryer;
$house = $base + $power;
$grid = $house - $pv;
$pvId = enelixDemoRequiredId($dashboard, 'PVLeistung');
$houseId = enelixDemoRequiredId($dashboard, 'Hausverbrauch');
$loadId = enelixDemoRequiredId($dashboard, 'Verbraucherleistung');
SetValue($gridId, round($grid, 1));
SetValue($pvId, round($pv, 1));
SetValue($houseId, round($house, 1));
SetValue($loadId, (float) $power);
SetValue(enelixDemoRequiredId($dashboard, 'Boiler1Leistung'), (float) $boiler1);
SetValue(enelixDemoRequiredId($dashboard, 'Boiler2Leistung'), (float) $boiler2);
SetValue(enelixDemoRequiredId($dashboard, 'EntfeuchterLeistung'), (float) $dryer);
SetValue(enelixDemoRequiredId($technical, 'EnergieflussPVkW'), round($pv / 1000.0, 3));
SetValue(enelixDemoRequiredId($technical, 'EnergieflussNetzkW'), round($grid / 1000.0, 3));
SetValue(enelixDemoRequiredId($technical, 'EnergieflussGrundlastkW'), round($base / 1000.0, 3));
SetValue(enelixDemoRequiredId($technical, 'EnergieflussBoiler1kW'), round($boiler1 / 1000.0, 3));
SetValue(enelixDemoRequiredId($technical, 'EnergieflussBoiler2kW'), round($boiler2 / 1000.0, 3));
SetValue(enelixDemoRequiredId($technical, 'EnergieflussEntfeuchterkW'), round($dryer / 1000.0, 3));
SetValue(enelixDemoRequiredId($technical, 'DiagrammHausverbrauchkW'), round($house / 1000.0, 3));
SetValue(enelixDemoRequiredId($technical, 'DiagrammVerbraucherkW'), round($power / 1000.0, 3));
$history = enelixDemoRequiredId($root, 'Historie');
$energyIds = [
'pv' => enelixDemoRequiredId($history, 'PVEnergie'),
'house' => enelixDemoRequiredId($history, 'HausEnergie'),
'import' => enelixDemoRequiredId($history, 'NetzbezugEnergie'),
'export' => enelixDemoRequiredId($history, 'EinspeisungEnergie'),
];
if ($source === 'Timer') {
$hours = $minutes / 60.0;
enelixDemoAddEnergy($energyIds['pv'], $pv, $hours);
enelixDemoAddEnergy($energyIds['house'], $house, $hours);
enelixDemoAddEnergy($energyIds['import'], max(0.0, $grid), $hours);
enelixDemoAddEnergy($energyIds['export'], max(0.0, -$grid), $hours);
}
}
function enelixDemoRenderFlow(array $state): string
{
$format = static fn (float $value): string => number_format($value, 0, ',', "'");
$hour = (float) $state['hour'];
$time = sprintf('%02d:%02d', (int) floor($hour), (int) round(($hour - floor($hour)) * 60) % 60);
$grid = (float) $state['grid'];
$gridLabel = $grid < -25 ? 'Einspeisung' : ($grid > 25 ? 'Netzbezug' : 'Ausgeglichen');
$gridArrow = $grid < -25 ? '&rarr;' : ($grid > 25 ? '&larr;' : '&harr;');
$gridClass = abs($grid) <= 250 ? 'good' : ($grid < 0 ? 'export' : 'import');
$b1Class = (float) $state['boiler1'] > 0 ? 'on' : '';
$b2Class = (float) $state['boiler2'] > 0 ? 'on' : '';
$dryClass = (float) $state['dryer'] > 0 ? 'on' : '';
return '<style>
.enelix-flow{box-sizing:border-box;font-family:Inter,Segoe UI,Arial,sans-serif;background:#10151c;color:#f4f7fa;padding:18px;min-height:420px}
.enelix-flow *{box-sizing:border-box}.enelix-head{display:flex;justify-content:space-between;align-items:end;margin-bottom:18px}
.enelix-title{font-size:24px;font-weight:750;letter-spacing:0}.enelix-sub{color:#9dacbd;font-size:13px}
.enelix-line{display:grid;grid-template-columns:minmax(120px,1fr) 48px minmax(140px,1.2fr) 48px minmax(120px,1fr);align-items:center;gap:8px}
.enelix-node{border:1px solid #34404d;background:#17202a;padding:16px;text-align:center;border-radius:8px;min-height:92px}
.enelix-node small{display:block;color:#9dacbd;margin-bottom:8px}.enelix-node b{font-size:22px}
.enelix-node.solar{border-color:#b99628}.enelix-node.bus{border-color:#2786ac}.enelix-node.good{border-color:#2bb673}
.enelix-node.export{border-color:#38a3db}.enelix-node.import{border-color:#e95d5d}.enelix-arrow{text-align:center;font-size:30px;color:#9dacbd}
.enelix-loads{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:10px;margin-top:18px}
.enelix-load{border-left:4px solid #566371;background:#17202a;padding:13px 14px;border-radius:6px}
.enelix-load.on{border-left-color:#2bb673}.enelix-load b{display:block;font-size:17px;margin:4px 0}.enelix-load span{color:#9dacbd;font-size:12px}
.enelix-foot{display:flex;justify-content:space-between;gap:12px;margin-top:16px;padding-top:12px;border-top:1px solid #2a3541;color:#9dacbd;font-size:12px}
@media(max-width:640px){.enelix-line{grid-template-columns:1fr}.enelix-arrow{transform:rotate(90deg)}.enelix-loads{grid-template-columns:1fr}.enelix-head{align-items:start;flex-direction:column;gap:4px}}
</style><div class="enelix-flow"><div class="enelix-head"><div><div class="enelix-title">Enelix 2 Energiefluss</div><div class="enelix-sub">Manager regelt drei flexible Verbraucher</div></div><div class="enelix-sub">Simulation ' . $time . ' Uhr</div></div>
<div class="enelix-line"><div class="enelix-node solar"><small>PV-Erzeugung</small><b>' . $format((float) $state['pv']) . ' W</b></div><div class="enelix-arrow">&rarr;</div><div class="enelix-node bus"><small>Haus + flexible Lasten</small><b>' . $format((float) $state['base'] + (float) $state['boiler1'] + (float) $state['boiler2'] + (float) $state['dryer']) . ' W</b></div><div class="enelix-arrow">' . $gridArrow . '</div><div class="enelix-node ' . $gridClass . '"><small>' . $gridLabel . '</small><b>' . $format(abs($grid)) . ' W</b></div></div>
<div class="enelix-loads"><div class="enelix-load ' . $b1Class . '"><span>Priorität 1</span><b>Boiler 1 · ' . $format((float) $state['boiler1']) . ' W</b><span>' . number_format((float) $state['temp1'], 1, ',', '') . ' °C</span></div><div class="enelix-load ' . $b2Class . '"><span>Priorität 2</span><b>Boiler 2 · ' . $format((float) $state['boiler2']) . ' W</b><span>' . number_format((float) $state['temp2'], 1, ',', '') . ' °C</span></div><div class="enelix-load ' . $dryClass . '"><span>Priorität 3</span><b>Entfeuchter · ' . $format((float) $state['dryer']) . ' W</b><span>einstufiger Verbraucher</span></div></div>
<div class="enelix-foot"><span>Grundlast ' . $format((float) $state['base']) . ' W</span><span>Manager: ' . htmlspecialchars((string) $state['manager'], ENT_QUOTES, 'UTF-8') . '</span></div></div>';
}
function enelixDemoControlledPower(array $contacts): int
{
$boiler1 = (bool) GetValue($contacts['boiler12']) ? 2400 : ((bool) GetValue($contacts['boiler11']) ? 1200 : 0);
$boiler2 = (bool) GetValue($contacts['boiler22']) ? 3000 : ((bool) GetValue($contacts['boiler21']) ? 1500 : 0);
return $boiler1 + $boiler2 + ((bool) GetValue($contacts['dryer']) ? 900 : 0);
}
function enelixDemoUpdateTemperature(int $variableId, int $power, int $minutes): void
{
$current = (float) GetValue($variableId);
$delta = (($power / 1000.0) * 0.03 * $minutes) - (0.01 * $minutes);
SetValue($variableId, round(min(70.0, max(38.0, $current + $delta)), 2));
}
function enelixDemoValidateRuntime(): void
{
$version = IPS_GetKernelVersion();
if (version_compare($version, '8.0', '<') || version_compare($version, '9.0', '>=')) {
throw new RuntimeException('Die Demo benötigt IP-Symcon 8.x; aktiv ist ' . $version . '.');
}
$available = IPS_GetModuleList();
foreach ([
ENELIX_DEMO_MANAGER_MODULE,
ENELIX_DEMO_BOILER_MODULE,
ENELIX_DEMO_SINGLE_MODULE,
ENELIX_DEMO_DUMMY_MODULE,
ENELIX_DEMO_ENERGY_DISTRIBUTION_MODULE,
] as $moduleId) {
if (!in_array($moduleId, $available, true)) {
throw new RuntimeException('Benötigtes Enelix-Modul fehlt: ' . $moduleId);
}
}
}
function enelixDemoEnsureProfiles(): void
{
enelixDemoEnsureProfile('ENELIX.Demo.Power', 2, -12000, 12000, 100, 0, ' W');
enelixDemoEnsureProfile('ENELIX.Demo.PowerPositive', 2, 0, 12000, 100, 0, ' W');
enelixDemoEnsureProfile('ENELIX.Demo.Hour', 2, 0, 23.75, 0.25, 2, ' h');
enelixDemoEnsureProfile('ENELIX.Demo.Percent', 1, 0, 100, 5, 0, ' %');
enelixDemoEnsureProfile('ENELIX.Demo.Speed', 1, 1, 60, 1, 0, ' min');
enelixDemoEnsureProfile('ENELIX.Demo.Energy', 2, 0, 1000000, 0.01, 2, ' kWh');
}
function enelixDemoEnsureProfile(string $name, int $type, float $min, float $max, float $step, int $digits, string $suffix): void
{
if (!IPS_VariableProfileExists($name)) {
IPS_CreateVariableProfile($name, $type);
}
$profile = IPS_GetVariableProfile($name);
if ((int) $profile['ProfileType'] !== $type) {
throw new RuntimeException('Variablenprofil hat einen unpassenden Typ: ' . $name);
}
IPS_SetVariableProfileValues($name, $min, $max, $step);
IPS_SetVariableProfileDigits($name, $digits);
IPS_SetVariableProfileText($name, '', $suffix);
}
function enelixDemoEnsureCategory(int $parent, string $ident, string $name, int $position, bool $hidden = false): int
{
$id = enelixDemoFind($parent, $ident);
if ($id === 0) {
$id = IPS_CreateCategory();
IPS_SetParent($id, $parent);
IPS_SetIdent($id, $ident);
}
enelixDemoRequireType($id, 0);
IPS_SetName($id, $name);
IPS_SetPosition($id, $position);
IPS_SetHidden($id, $hidden);
return $id;
}
function enelixDemoEnsureVariable(int $parent, string $ident, string $name, int $type, string $profile, $default, int $position): int
{
$id = enelixDemoFind($parent, $ident);
$created = false;
if ($id === 0) {
$id = IPS_CreateVariable($type);
IPS_SetParent($id, $parent);
IPS_SetIdent($id, $ident);
$created = true;
}
enelixDemoRequireType($id, 2);
$variable = IPS_GetVariable($id);
if ((int) $variable['VariableType'] !== $type) {
throw new RuntimeException('Variablentyp-Konflikt bei ' . $ident);
}
IPS_SetName($id, $name);
IPS_SetPosition($id, $position);
if ($profile !== '') {
IPS_SetVariableCustomProfile($id, $profile);
}
if ($created) {
SetValue($id, $default);
}
return $id;
}
function enelixDemoEnsureChart(
int $parent,
string $ident,
string $name,
array $datasets,
int $position
): int {
$id = enelixDemoFind($parent, $ident);
if ($id === 0) {
$id = IPS_CreateMedia(4);
IPS_SetParent($id, $parent);
IPS_SetIdent($id, $ident);
}
enelixDemoRequireType($id, 5);
if ((int) IPS_GetMedia($id)['MediaType'] !== 4) {
throw new RuntimeException('Medientyp-Konflikt bei ' . $ident);
}
IPS_SetName($id, $name);
IPS_SetPosition($id, $position);
IPS_SetHidden($id, false);
IPS_SetMediaFile($id, 'media/' . $id . '.chart', false);
IPS_SetMediaContent(
$id,
base64_encode(json_encode(['datasets' => $datasets], JSON_THROW_ON_ERROR))
);
return $id;
}
function enelixDemoChartDataset(
int $variableId,
string $title,
string $strokeColor,
string $profile
): array {
return [
'variableID' => $variableId,
'fillColor' => 'clear',
'strokeColor' => $strokeColor,
'timeOffset' => 0,
'visible' => true,
'title' => $title,
'type' => 'line',
'side' => 'left',
'profile' => $profile,
];
}
function enelixDemoEnsureScript(int $parent, string $ident, string $name, string $content, int $position): int
{
$id = enelixDemoFind($parent, $ident);
if ($id === 0) {
$id = IPS_CreateScript(0);
IPS_SetParent($id, $parent);
IPS_SetIdent($id, $ident);
}
enelixDemoRequireType($id, 3);
IPS_SetName($id, $name);
IPS_SetPosition($id, $position);
IPS_SetScriptContent($id, $content);
return $id;
}
function enelixDemoEnsureInstance(int $parent, string $ident, string $name, string $moduleId, int $position): int
{
$id = enelixDemoFind($parent, $ident);
if ($id === 0) {
$id = IPS_CreateInstance($moduleId);
IPS_SetParent($id, $parent);
IPS_SetIdent($id, $ident);
}
enelixDemoRequireType($id, 1);
$actualModule = IPS_GetInstance($id)['ModuleInfo']['ModuleID'];
if ($actualModule !== $moduleId) {
throw new RuntimeException('Modulkonflikt bei Instanz ' . $ident);
}
IPS_SetName($id, $name);
IPS_SetPosition($id, $position);
return $id;
}
function enelixDemoEnsureLink(int $parent, string $ident, string $name, int $target, int $position): int
{
$id = enelixDemoFind($parent, $ident);
if ($id === 0) {
$id = IPS_CreateLink();
IPS_SetParent($id, $parent);
IPS_SetIdent($id, $ident);
}
enelixDemoRequireType($id, 6);
IPS_SetName($id, $name);
IPS_SetPosition($id, $position);
IPS_SetLinkTargetID($id, $target);
return $id;
}
function enelixDemoEnsureCyclicEvent(int $scriptId, string $ident, int $seconds): int
{
$id = enelixDemoFind($scriptId, $ident);
if ($id === 0) {
$id = IPS_CreateEvent(1);
IPS_SetParent($id, $scriptId);
IPS_SetIdent($id, $ident);
}
enelixDemoRequireType($id, 4);
IPS_SetName($id, 'Alle ' . $seconds . ' Sekunden');
IPS_SetEventCyclic($id, 0, 0, 0, 0, 1, $seconds);
IPS_SetEventAction(
$id,
'{346AA8C1-30E0-1663-78EF-93EFADFAC650}',
['SCRIPT' => 'IPS_RunScript(' . $scriptId . ');']
);
IPS_SetEventActive($id, true);
return $id;
}
function enelixDemoConfigure(int $instanceId, array $properties): void
{
foreach ($properties as $name => $value) {
IPS_SetProperty($instanceId, (string) $name, $value);
}
IPS_ApplyChanges($instanceId);
if ((int) IPS_GetInstance($instanceId)['InstanceStatus'] !== 102) {
throw new RuntimeException('Instanz ist nach der Konfiguration nicht aktiv: ' . IPS_GetName($instanceId));
}
}
function enelixDemoConfigureArchive(array $variableIds): void
{
$archive = enelixDemoFirstInstance(ENELIX_DEMO_ARCHIVE_MODULE);
if ($archive === 0) {
return;
}
foreach ($variableIds as $variableId) {
AC_SetLoggingStatus($archive, $variableId, true);
AC_SetAggregationType($archive, $variableId, 0);
AC_SetGraphStatus($archive, $variableId, true);
}
IPS_ApplyChanges($archive);
}
function enelixDemoConfigureVisualization(int $root, int $flow): void
{
$visualization = enelixDemoFirstInstance(ENELIX_DEMO_TILE_MODULE);
if ($visualization === 0) {
return;
}
IPS_SetProperty($visualization, 'BaseID', $root);
IPS_SetProperty($visualization, 'CategoryDisplay', 'Cards');
IPS_SetProperty($visualization, 'ShowGreeting', 3);
IPS_SetProperty($visualization, 'GreetingName', 'Enelix 2 Demo');
IPS_SetProperty($visualization, 'ShowDateTime', true);
IPS_SetProperty($visualization, 'Favorites', json_encode([['ObjectID' => $flow]], JSON_THROW_ON_ERROR));
IPS_ApplyChanges($visualization);
VISU_Reload($visualization);
}
function enelixDemoFirstInstance(string $moduleId): int
{
$ids = IPS_GetInstanceListByModuleID($moduleId);
return $ids === [] ? 0 : (int) $ids[0];
}
function enelixDemoFind(int $parent, string $ident): int
{
$id = @IPS_GetObjectIDByIdent($ident, $parent);
return $id === false ? 0 : (int) $id;
}
function enelixDemoRequiredId(int $parent, string $ident): int
{
$id = enelixDemoFind($parent, $ident);
if ($id === 0) {
throw new RuntimeException('Demo-Objekt fehlt: ' . $ident);
}
return $id;
}
function enelixDemoRequireType(int $objectId, int $type): void
{
$actual = (int) IPS_GetObject($objectId)['ObjectType'];
if ($actual !== $type) {
throw new RuntimeException('Objekttyp-Konflikt bei ID ' . $objectId);
}
}
function enelixDemoRemove(bool $confirmed = false): void
{
if (!$confirmed) {
throw new RuntimeException('Rollback nur mit enelixDemoRemove(true) ausführen.');
}
$root = enelixDemoFind(0, ENELIX_DEMO_ROOT_IDENT);
if ($root === 0) {
return;
}
$visualization = enelixDemoFirstInstance(ENELIX_DEMO_TILE_MODULE);
if ($visualization > 0 && (int) json_decode(IPS_GetConfiguration($visualization), true)['BaseID'] === $root) {
IPS_SetProperty($visualization, 'BaseID', 0);
IPS_ApplyChanges($visualization);
}
enelixDemoDeleteTree($root);
foreach (['ENELIX.Demo.Power', 'ENELIX.Demo.PowerPositive', 'ENELIX.Demo.Hour', 'ENELIX.Demo.Percent', 'ENELIX.Demo.Speed', 'ENELIX.Demo.Energy'] as $profile) {
if (IPS_VariableProfileExists($profile)) {
IPS_DeleteVariableProfile($profile);
}
}
}
function enelixDemoDeleteTree(int $objectId): void
{
foreach (array_reverse(IPS_GetChildrenIDs($objectId)) as $childId) {
enelixDemoDeleteTree((int) $childId);
}
$type = (int) IPS_GetObject($objectId)['ObjectType'];
if ($type === 1 && (int) IPS_GetInstance($objectId)['ConnectionID'] > 0) {
IPS_DisconnectInstance($objectId);
}
switch ($type) {
case 0: IPS_DeleteCategory($objectId); break;
case 1: IPS_DeleteInstance($objectId); break;
case 2: IPS_DeleteVariable($objectId); break;
case 3: IPS_DeleteScript($objectId, true); break;
case 4: IPS_DeleteEvent($objectId); break;
case 5: IPS_DeleteMedia($objectId, true); break;
case 6: IPS_DeleteLink($objectId); break;
default: throw new RuntimeException('Unbekannter Objekttyp: ' . $type);
}
}
function enelixDemoEnergyNode(
int $variableId,
string $name,
string $type,
int $infoVariableId = 0
): array {
return [
'VariableConfigType' => 0,
'VariableID' => $variableId,
'SecondaryVariableID' => 1,
'Name' => $name,
'Type' => $type,
'Grouped' => false,
'CalculationType' => 0,
'InfoVariableID' => $infoVariableId > 0 ? $infoVariableId : 1,
'Invert' => false,
];
}
function enelixDemoConfigureEnergyDistribution(int $instanceId, array $nodes): void
{
enelixDemoConfigure($instanceId, [
'Calculation' => true,
'Config' => json_encode($nodes, JSON_THROW_ON_ERROR),
'EnableLimits' => false,
'Restrictions' => '[]',
]);
}
function enelixDemoLinkInstanceVariables(int $categoryId, int $instanceId, int $sensorId): void
{
enelixDemoEnsureLink($categoryId, 'SensorTemperatur', 'Gemessene Temperatur', $sensorId, 5);
$position = 10;
foreach (IPS_GetChildrenIDs($instanceId) as $childId) {
if ((int) IPS_GetObject($childId)['ObjectType'] !== 2) {
continue;
}
$object = IPS_GetObject($childId);
$ident = $object['ObjectIdent'] !== '' ? $object['ObjectIdent'] : (string) $childId;
enelixDemoEnsureLink(
$categoryId,
'Wert_' . preg_replace('/[^A-Za-z0-9_]/', '_', $ident),
(string) $object['ObjectName'],
(int) $childId,
$position
);
$position += 10;
}
}
function enelixDemoAddEnergy(int $variableId, float $powerW, float $hours): void
{
$energy = (float) GetValue($variableId) + (($powerW * $hours) / 1000.0);
SetValue($variableId, round($energy, 4));
}
function enelixDemoUpdateCharts(int $historyId, array $powerIds, array $energyIds): void
{
$archive = enelixDemoFirstInstance(ENELIX_DEMO_ARCHIVE_MODULE);
if ($archive === 0) {
return;
}
$end = time();
$start = $end - 1200;
$powerSeries = [
['name' => 'PV', 'color' => '#f4c542', 'values' => enelixDemoLoggedSeries($archive, $powerIds[0], $start, $end)],
['name' => 'Hausverbrauch', 'color' => '#2bb673', 'values' => enelixDemoLoggedSeries($archive, $powerIds[1], $start, $end)],
['name' => 'Netzleistung', 'color' => '#e95d5d', 'values' => enelixDemoLoggedSeries($archive, $powerIds[2], $start, $end)],
];
$energySeries = [
['name' => 'PV', 'color' => '#f4c542', 'values' => enelixDemoLoggedSeries($archive, $energyIds['pv'], $start, $end)],
['name' => 'Haus', 'color' => '#2bb673', 'values' => enelixDemoLoggedSeries($archive, $energyIds['house'], $start, $end)],
['name' => 'Netzbezug', 'color' => '#e95d5d', 'values' => enelixDemoLoggedSeries($archive, $energyIds['import'], $start, $end)],
['name' => 'Einspeisung', 'color' => '#38a3db', 'values' => enelixDemoLoggedSeries($archive, $energyIds['export'], $start, $end)],
];
SetValue(
enelixDemoRequiredId($historyId, 'Leistungsdiagramm'),
enelixDemoRenderChart('Leistungsverlauf · letzte 20 Minuten', $powerSeries, 'W', $start, $end)
);
SetValue(
enelixDemoRequiredId($historyId, 'Energiediagramm'),
enelixDemoRenderChart('Kumulierte Energie · Simulationszeit', $energySeries, 'kWh', $start, $end)
);
}
function enelixDemoLoggedSeries(int $archiveId, int $variableId, int $start, int $end): array
{
$values = array_reverse(AC_GetLoggedValues($archiveId, $variableId, $start, $end, 0));
if ($values === []) {
return [['time' => $end, 'value' => (float) GetValue($variableId)]];
}
$step = max(1, (int) ceil(count($values) / 90));
$result = [];
foreach ($values as $index => $value) {
if ($index % $step !== 0 && $index !== count($values) - 1) {
continue;
}
$result[] = [
'time' => (int) $value['TimeStamp'],
'value' => (float) $value['Value'],
];
}
return $result;
}
function enelixDemoRenderChart(
string $title,
array $series,
string $unit,
int $start,
int $end
): string {
$allValues = [0.0];
foreach ($series as $line) {
foreach ($line['values'] as $point) {
$allValues[] = (float) $point['value'];
}
}
$min = min($allValues);
$max = max($allValues);
if (abs($max - $min) < 0.0001) {
$max = $min + 1.0;
}
$left = 72.0;
$top = 34.0;
$width = 650.0;
$height = 210.0;
$timeRange = max(1, $end - $start);
$valueRange = $max - $min;
$grid = '';
for ($index = 0; $index <= 4; $index++) {
$y = $top + (($height / 4) * $index);
$value = $max - (($valueRange / 4) * $index);
$grid .= '<line x1="' . $left . '" y1="' . $y . '" x2="' . ($left + $width) . '" y2="' . $y . '" />';
$grid .= '<text x="' . ($left - 10) . '" y="' . ($y + 4) . '" text-anchor="end">'
. number_format($value, $unit === 'W' ? 0 : 2, ',', "'") . '</text>';
}
$lines = '';
$legend = '';
foreach ($series as $line) {
$points = [];
foreach ($line['values'] as $point) {
$x = $left + ((((int) $point['time'] - $start) / $timeRange) * $width);
$y = $top + (($max - (float) $point['value']) / $valueRange * $height);
$points[] = round($x, 1) . ',' . round($y, 1);
}
$color = htmlspecialchars((string) $line['color'], ENT_QUOTES, 'UTF-8');
$name = htmlspecialchars((string) $line['name'], ENT_QUOTES, 'UTF-8');
$lines .= '<polyline points="' . implode(' ', $points) . '" stroke="' . $color . '" />';
$legend .= '<span><i style="background:' . $color . '"></i>' . $name . '</span>';
}
return '<style>
.enelix-chart{font-family:Inter,Segoe UI,Arial,sans-serif;background:#10151c;color:#f4f7fa;padding:14px;border-radius:6px}
.enelix-chart h3{font-size:17px;margin:0 0 8px}.enelix-chart svg{display:block;width:100%;height:auto;min-height:230px}
.enelix-chart .grid line{stroke:#33404d;stroke-width:1}.enelix-chart text{fill:#9dacbd;font-size:11px}
.enelix-chart polyline{fill:none;stroke-width:3;stroke-linejoin:round;stroke-linecap:round}
.enelix-legend{display:flex;flex-wrap:wrap;gap:14px;margin-top:4px;color:#c5ced8;font-size:12px}
.enelix-legend span{display:flex;align-items:center;gap:6px}.enelix-legend i{display:inline-block;width:18px;height:3px}
</style><div class="enelix-chart"><h3>' . htmlspecialchars($title, ENT_QUOTES, 'UTF-8') . '</h3>
<svg viewBox="0 0 760 280" role="img" aria-label="' . htmlspecialchars($title, ENT_QUOTES, 'UTF-8') . '">
<g class="grid">' . $grid . '</g>' . $lines . '
<text x="' . $left . '" y="266">' . date('H:i', $start) . '</text>
<text x="' . ($left + $width) . '" y="266" text-anchor="end">' . date('H:i', $end) . '</text>
<text x="18" y="20">' . htmlspecialchars($unit, ENT_QUOTES, 'UTF-8') . '</text>
</svg><div class="enelix-legend">' . $legend . '</div></div>';
}
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"id": "{E0185158-0DA9-4D44-BAA7-B06D3D786276}", "id": "{E0185158-0DA9-4D44-BAA7-B06D3D786276}",
"name": "Enelix EMS", "name": "Enelix EMS",
"author": "Belevo AG", "author": "Enelix",
"url": "https://git.belevo.ch/ENELIX/Enelix-EMS", "url": "https://git.belevo.ch/ENELIX/Enelix-EMS",
"compatibility": { "compatibility": {
"version": "8.0" "version": "8.0"
+176
View File
@@ -0,0 +1,176 @@
<?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,
bool $peakbetrieb,
bool $peakSperreBeiMindestlaufzeitAnbieten = true
): 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) {
return [$istleistung];
}
if ($mindestlaufzeitErzwungen) {
return $peakbetrieb && $peakSperreBeiMindestlaufzeitAnbieten
? [0, $nennleistung]
: [$nennleistung];
}
if ($peakbetrieb) {
return [0];
}
return [0, $nennleistung];
}
public static function freigabezeitpunkt(
bool $eingeschaltet,
int $bestaetigtSeit,
int $mindesteinschaltdauer,
int $mindestausschaltdauer
): int {
if ($bestaetigtSeit < 0 || $mindesteinschaltdauer < 0 || $mindestausschaltdauer < 0) {
throw new InvalidArgumentException('Zeitwerte duerfen nicht negativ sein.');
}
return $bestaetigtSeit + ($eingeschaltet ? $mindesteinschaltdauer : $mindestausschaltdauer);
}
public static function restMindestzeit(int $freigabezeitpunkt, int $jetzt): int
{
if ($freigabezeitpunkt < 0 || $jetzt < 0) {
throw new InvalidArgumentException('Zeitwerte duerfen nicht negativ sein.');
}
return max(0, $freigabezeitpunkt - $jetzt);
}
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);
}
}
+208
View File
@@ -0,0 +1,208 @@
<?php
declare(strict_types=1);
namespace Belevo\EnelixEMS;
use InvalidArgumentException;
use RuntimeException;
final class LadestationAdapter
{
public const GO_E_ALT = 1;
public const GO_E_GEMINI = 2;
public const SMART_ME_PICO = 3;
/**
* @param array<string, string> $konfiguration
* @return array<string, mixed>
*/
public static function statusAnfrage(int $geraetetyp, array $konfiguration): array
{
if ($geraetetyp === self::GO_E_ALT) {
return self::anfrage('GET', self::lokaleBasisUrl($konfiguration) . '/mqtt?payload=');
}
if ($geraetetyp === self::GO_E_GEMINI) {
return self::anfrage('GET', self::lokaleBasisUrl($konfiguration) . '/api/status');
}
if ($geraetetyp === self::SMART_ME_PICO) {
$id = self::pflichtwert($konfiguration, 'GeraeteID');
return self::anfrage(
'GET',
'https://api.smart-me.com/pico/charging/' . rawurlencode($id),
$konfiguration
);
}
throw new InvalidArgumentException('Unbekannter Geraetetyp.');
}
/**
* @param array<string, string> $konfiguration
* @return list<array<string, mixed>>
*/
public static function steuerAnfragen(
int $geraetetyp,
array $konfiguration,
int $ladestrom
): array {
if ($ladestrom < 0 || $ladestrom > 32 || ($ladestrom > 0 && $ladestrom < 6)) {
throw new InvalidArgumentException('Ladestrom muss 0 oder 6 bis 32 A betragen.');
}
if ($geraetetyp === self::GO_E_ALT) {
$basis = self::lokaleBasisUrl($konfiguration) . '/mqtt?payload=';
if ($ladestrom === 0) {
return [self::anfrage('GET', $basis . 'alw=0')];
}
return [
self::anfrage('GET', $basis . 'alw=1'),
self::anfrage('GET', $basis . 'amp=' . $ladestrom),
];
}
if ($geraetetyp === self::GO_E_GEMINI) {
$basis = self::lokaleBasisUrl($konfiguration) . '/api/set?';
if ($ladestrom === 0) {
return [self::anfrage('GET', $basis . 'frc=1&sua=false')];
}
return [
self::anfrage('GET', $basis . 'frc=2'),
self::anfrage('GET', $basis . 'amp=' . $ladestrom),
];
}
if ($geraetetyp === self::SMART_ME_PICO) {
$seriennummer = self::pflichtwert($konfiguration, 'Seriennummer');
return [self::anfrage(
'POST',
'https://api.smart-me.com/pico/loadmanagementgroup/current/'
. rawurlencode($seriennummer)
. '?current=' . ($ladestrom * 1000),
$konfiguration
)];
}
throw new InvalidArgumentException('Unbekannter Geraetetyp.');
}
/**
* @return array{Rohstatus: int, Istleistung_W: float}
*/
public static function dekodiereStatus(int $geraetetyp, string $antwort): array
{
$daten = json_decode($antwort, true, 512, JSON_THROW_ON_ERROR);
if (!is_array($daten)) {
throw new RuntimeException('Die Geraeteantwort ist kein JSON-Objekt.');
}
if ($geraetetyp === self::GO_E_ALT || $geraetetyp === self::GO_E_GEMINI) {
if (!isset($daten['car'], $daten['nrg'])
|| !is_numeric($daten['car'])
|| !is_array($daten['nrg'])
|| !isset($daten['nrg'][11])
|| !is_numeric($daten['nrg'][11])
) {
throw new RuntimeException('Die go-e-Antwort enthaelt nicht alle Statusfelder.');
}
$faktor = $geraetetyp === self::GO_E_ALT ? 10.0 : 1.0;
return [
'Rohstatus' => (int) $daten['car'],
'Istleistung_W' => (float) $daten['nrg'][11] * $faktor,
];
}
if ($geraetetyp === self::SMART_ME_PICO) {
if (!isset($daten['State'], $daten['ActiveChargingPower'])
|| !is_numeric($daten['State'])
|| !is_numeric($daten['ActiveChargingPower'])
) {
throw new RuntimeException('Die Pico-Antwort enthaelt nicht alle Statusfelder.');
}
return [
'Rohstatus' => (int) $daten['State'],
'Istleistung_W' => round((float) $daten['ActiveChargingPower'] * 1000.0),
];
}
throw new InvalidArgumentException('Unbekannter Geraetetyp.');
}
/**
* @param array<string, string> $konfiguration
* @return array<string, mixed>
*/
private static function anfrage(
string $methode,
string $url,
array $konfiguration = []
): array {
$anfrage = ['Methode' => $methode, 'URL' => $url];
if (str_starts_with($url, 'https://api.smart-me.com/')) {
$anfrage['Benutzername'] = self::pflichtwert($konfiguration, 'Benutzername');
$anfrage['Passwort'] = self::pflichtwert($konfiguration, 'Passwort');
}
return $anfrage;
}
/** @param array<string, string> $konfiguration */
private static function lokaleBasisUrl(array $konfiguration): string
{
$adresse = trim(self::pflichtwert($konfiguration, 'Geraeteadresse'));
if (str_contains($adresse, '/') || str_contains($adresse, '?')) {
throw new InvalidArgumentException('Geraeteadresse muss ein Hostname oder eine IP-Adresse sein.');
}
return 'http://' . $adresse;
}
/** @param array<string, string> $konfiguration */
private static function pflichtwert(array $konfiguration, string $name): string
{
$wert = trim($konfiguration[$name] ?? '');
if ($wert === '') {
throw new InvalidArgumentException($name . ' darf nicht leer sein.');
}
return $wert;
}
}
final class LadestationTransport
{
/** @var callable(array<string, mixed>): string */
private $anfrageAusfuehren;
/** @param callable(array<string, mixed>): string $anfrageAusfuehren */
public function __construct(callable $anfrageAusfuehren)
{
$this->anfrageAusfuehren = $anfrageAusfuehren;
}
/** @param array<string, string> $konfiguration */
public function statusLesen(int $geraetetyp, array $konfiguration): string
{
return ($this->anfrageAusfuehren)(
LadestationAdapter::statusAnfrage($geraetetyp, $konfiguration)
);
}
/** @param array<string, string> $konfiguration */
public function ladestromSetzen(
int $geraetetyp,
array $konfiguration,
int $ladestrom
): void {
foreach (LadestationAdapter::steuerAnfragen($geraetetyp, $konfiguration, $ladestrom) as $anfrage) {
($this->anfrageAusfuehren)($anfrage);
}
}
}
+146
View File
@@ -0,0 +1,146 @@
<?php
declare(strict_types=1);
namespace Belevo\EnelixEMS;
use InvalidArgumentException;
final class LadestationRegler
{
public const PHASEN_UNBEKANNT = 0;
public const PHASEN_EIN = 1;
public const PHASEN_DREI = 3;
/** @var list<int> */
private const LEISTUNGEN_EINPHASIG = [
1450, 1690, 1930, 2170, 2410, 2650, 2890, 3130, 3370,
3610, 3850, 4090, 4330, 4570, 4810, 5050, 5290, 5530,
5770, 6010, 6250, 6490, 6730, 6970, 7210, 7450, 7690,
];
/** @var list<int> */
private const LEISTUNGEN_DREIPHASIG = [
4150, 4850, 5550, 6250, 6950, 7600, 8300, 9000, 9700,
10300, 11000, 11750, 12450, 13150, 13850, 14550, 15250,
15900, 16600, 17300, 18000, 18700, 19400, 20100, 20800,
21500, 22000,
];
/**
* Die Schwelle, die Voll-Erkennung und der Zuschlag von 2.5 A stammen
* aus der Enelix-1-Ladestation und bleiben bewusst kompatibel.
*
* @param array{Rohstatus: int, Istleistung_W: float} $rohstatus
* @return array<string, int|float|bool>
*/
public static function normalisiereStatus(array $rohstatus, bool $warVerbunden): array
{
$verbunden = $rohstatus['Rohstatus'] !== 1;
$leistung = max(0.0, $rohstatus['Istleistung_W']);
$phasen = self::PHASEN_UNBEKANNT;
$ladestrom = 0.0;
$geladen = false;
if ($verbunden) {
$phasen = $leistung > 7500.0 ? self::PHASEN_DREI : self::PHASEN_EIN;
$nenner = $phasen === self::PHASEN_EIN ? 230.0 : 1.71 * 400.0;
$ladestrom = $leistung / $nenner;
$ermittelterMaximalstrom = 2.5 + $ladestrom;
$geladen = $warVerbunden && $ermittelterMaximalstrom < 6.0;
}
if (!$verbunden) {
$fahrzeugstatus = 1;
} elseif ($geladen) {
$fahrzeugstatus = 4;
} elseif ($leistung > 0.0) {
$fahrzeugstatus = 3;
} else {
$fahrzeugstatus = 2;
}
return [
'FahrzeugVerbunden' => $verbunden,
'FahrzeugGeladen' => $geladen,
'Fahrzeugstatus' => $fahrzeugstatus,
'Phasenzahl' => $phasen,
'Istleistung_W' => $leistung,
'Ladestrom_A' => $ladestrom,
];
}
/** @return list<int> */
public static function leistungsangebot(
bool $aktiv,
bool $ladefreigabe,
bool $verbunden,
bool $geladen,
int $phasenzahl,
int $maximalerLadestrom,
bool $solarladen,
bool $peakbetrieb
): array {
if (!$aktiv || !$ladefreigabe || !$verbunden || $geladen) {
return [0];
}
$stufen = self::leistungsstufen($phasenzahl, $maximalerLadestrom);
if ($stufen === []) {
return [0];
}
if ($solarladen) {
return $peakbetrieb ? [0] : array_merge([0], $stufen);
}
if ($peakbetrieb) {
return array_merge([0], $stufen);
}
return [$stufen[count($stufen) - 1]];
}
public static function stromFuerLeistung(int $leistung, int $phasenzahl): int
{
if ($leistung <= 0) {
return 0;
}
$stufen = self::stufenFuerPhasen($phasenzahl);
if ($leistung < $stufen[0]) {
return 0;
}
$strom = 6;
foreach ($stufen as $index => $schwelle) {
if ($leistung < $schwelle) {
break;
}
$strom = 6 + $index;
}
return min(32, $strom);
}
/** @return list<int> */
private static function leistungsstufen(int $phasenzahl, int $maximalerLadestrom): array
{
if ($maximalerLadestrom < 6 || $maximalerLadestrom > 32) {
throw new InvalidArgumentException('MaximalerLadestrom muss zwischen 6 und 32 A liegen.');
}
return array_slice(self::stufenFuerPhasen($phasenzahl), 0, $maximalerLadestrom - 5);
}
/** @return list<int> */
private static function stufenFuerPhasen(int $phasenzahl): array
{
if ($phasenzahl === self::PHASEN_EIN) {
return self::LEISTUNGEN_EINPHASIG;
}
if ($phasenzahl === self::PHASEN_DREI) {
return self::LEISTUNGEN_DREIPHASIG;
}
throw new InvalidArgumentException('Phasenzahl muss 1 oder 3 sein.');
}
}
+147
View File
@@ -0,0 +1,147 @@
<?php
declare(strict_types=1);
namespace Belevo\EnelixEMS;
use DateTimeImmutable;
use InvalidArgumentException;
use Throwable;
final class Lizenzpruefung
{
public const MANAGER_STANDARD = 'manager_standard';
public const MANAGER_PEAK = 'manager_peak';
public static function normalisiereCode(string $code): string
{
return strtoupper((string) preg_replace('/[^A-Z0-9]/i', '', $code));
}
public static function istLizenzcodeGueltig(string $code): bool
{
return preg_match(
'/^ENX[A-HJ-NP-Z2-9]{16}$/',
self::normalisiereCode($code)
) === 1;
}
/**
* @param array<string, mixed> $lease
*/
public static function pruefeLease(
array $lease,
string $installationID,
int $lastspitzenmodus,
?int $zeitpunkt = null
): void {
if (($lease['status'] ?? null) !== 'active') {
throw new InvalidArgumentException('Die Lizenz ist nicht aktiv.');
}
if (($lease['installationId'] ?? null) !== $installationID) {
throw new InvalidArgumentException('Die Lizenz gehoert zu einer anderen Installation.');
}
if (isset($lease['development']) && !is_bool($lease['development'])) {
throw new InvalidArgumentException('Der Entwicklungsstatus der Lizenz ist ungueltig.');
}
$ausgestelltAm = self::zeitstempel($lease, 'issuedAt');
$erneuernAb = self::zeitstempel($lease, 'refreshAfter');
$offlineBis = self::zeitstempel($lease, 'offlineUntil');
if ($ausgestelltAm > $erneuernAb || $erneuernAb > $offlineBis) {
throw new InvalidArgumentException('Die Lizenzzeitpunkte sind widerspruechlich.');
}
if (($zeitpunkt ?? time()) >= $offlineBis) {
throw new InvalidArgumentException('Die Offline-Freigabe ist abgelaufen.');
}
$berechtigungen = $lease['entitlements'] ?? null;
if (!is_array($berechtigungen)) {
throw new InvalidArgumentException('Die Lizenz enthaelt keine Berechtigungen.');
}
$standard = false;
$peak = false;
foreach ($berechtigungen as $berechtigung) {
if (!is_array($berechtigung)
|| ($berechtigung['status'] ?? null) !== 'active'
|| !is_int($berechtigung['quantity'] ?? null)
|| $berechtigung['quantity'] < 1
) {
continue;
}
$kennung = $berechtigung['catalog_key'] ?? $berechtigung['sku'] ?? null;
$standard = $standard || $kennung === self::MANAGER_STANDARD;
$peak = $peak || $kennung === self::MANAGER_PEAK;
}
if (!$standard && !$peak) {
throw new InvalidArgumentException('Die Manager-Berechtigung fehlt.');
}
if ($lastspitzenmodus !== 0 && !$peak) {
throw new InvalidArgumentException('Peak Shaving ist nicht lizenziert.');
}
}
/**
* @param array<string, mixed> $lease
*/
public static function istLeaseGueltig(
array $lease,
string $installationID,
int $lastspitzenmodus,
?int $zeitpunkt = null
): bool {
try {
self::pruefeLease($lease, $installationID, $lastspitzenmodus, $zeitpunkt);
return true;
} catch (Throwable $fehler) {
return false;
}
}
/**
* @param array<string, mixed> $lease
*/
public static function erneuerungFaellig(array $lease, ?int $zeitpunkt = null): bool
{
try {
return ($zeitpunkt ?? time()) >= self::zeitstempel($lease, 'refreshAfter');
} catch (Throwable $fehler) {
return true;
}
}
/**
* @param array<string, mixed> $lease
*/
public static function offlineBis(array $lease): int
{
return self::zeitstempel($lease, 'offlineUntil');
}
/**
* @param array<string, mixed> $lease
*/
public static function erneuernAb(array $lease): int
{
return self::zeitstempel($lease, 'refreshAfter');
}
/**
* @param array<string, mixed> $lease
*/
private static function zeitstempel(array $lease, string $feld): int
{
$wert = $lease[$feld] ?? null;
if (!is_string($wert) || trim($wert) === '') {
throw new InvalidArgumentException($feld . ' fehlt in der Lizenz.');
}
try {
return (new DateTimeImmutable($wert))->getTimestamp();
} catch (Throwable $fehler) {
throw new InvalidArgumentException($feld . ' ist kein gueltiger ISO-8601-Zeitpunkt.', 0, $fehler);
}
}
}
+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 = Nachrichtenvertrag::BETRIEBSART_PV;
public const BETRIEBSART_PEAK = Nachrichtenvertrag::BETRIEBSART_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. * Empfaengt den vollstaendigen aktuellen Datensatz eines Verbrauchers.
* *
* Dies ist die einzige fachliche Empfangsmethode des Managers.
*
* @param array<string, mixed> $daten * @param array<string, mixed> $daten
*/ */
public function VerbraucherdatenEmpfangen(array $daten): void; public function VerbraucherdatenEmpfangen(array $daten): void;
} }
+22 -4
View File
@@ -8,7 +8,10 @@ use InvalidArgumentException;
final class Nachrichtenvertrag final class Nachrichtenvertrag
{ {
public const VERSION = '3.0'; public const VERSION = '4.0';
public const BETRIEBSART_PV = 'PV';
public const BETRIEBSART_PEAK = 'Peak';
public const LEISTUNGSQUELLE_NICHT_VORHANDEN = 0; public const LEISTUNGSQUELLE_NICHT_VORHANDEN = 0;
public const LEISTUNGSQUELLE_BERECHNET = 1; public const LEISTUNGSQUELLE_BERECHNET = 1;
@@ -19,11 +22,12 @@ final class Nachrichtenvertrag
*/ */
public static function pruefeManagerdaten(array $daten): void public static function pruefeManagerdaten(array $daten): void
{ {
self::pruefeSchluessel($daten, ['Kopf', 'Sollleistung_W'], 'Managerdaten'); self::pruefeSchluessel($daten, ['Kopf', 'Betriebsart', 'Sollleistung_W'], 'Managerdaten');
self::pruefeKopf($daten['Kopf']); self::pruefeKopf($daten['Kopf']);
self::pruefeBetriebsart($daten['Betriebsart']);
if (!is_int($daten['Sollleistung_W'])) { if ($daten['Sollleistung_W'] !== null && !is_int($daten['Sollleistung_W'])) {
throw new InvalidArgumentException('Sollleistung_W muss eine Ganzzahl sein.'); throw new InvalidArgumentException('Sollleistung_W muss eine Ganzzahl oder null sein.');
} }
} }
@@ -36,6 +40,7 @@ final class Nachrichtenvertrag
$daten, $daten,
[ [
'Kopf', 'Kopf',
'Betriebsart',
'PrioritaetPV', 'PrioritaetPV',
'PrioritaetPeak', 'PrioritaetPeak',
'Leistungswerte_W', 'Leistungswerte_W',
@@ -49,6 +54,7 @@ final class Nachrichtenvertrag
); );
self::pruefeKopf($daten['Kopf']); self::pruefeKopf($daten['Kopf']);
self::pruefeBetriebsart($daten['Betriebsart']);
self::pruefePrioritaet($daten['PrioritaetPV'], 'PrioritaetPV'); self::pruefePrioritaet($daten['PrioritaetPV'], 'PrioritaetPV');
self::pruefePrioritaet($daten['PrioritaetPeak'], 'PrioritaetPeak'); self::pruefePrioritaet($daten['PrioritaetPeak'], 'PrioritaetPeak');
self::pruefeLeistungswerte($daten['Leistungswerte_W']); self::pruefeLeistungswerte($daten['Leistungswerte_W']);
@@ -93,6 +99,18 @@ final class Nachrichtenvertrag
} }
} }
/** @param mixed $betriebsart */
private static function pruefeBetriebsart($betriebsart): void
{
if (!is_string($betriebsart) || !in_array(
$betriebsart,
[self::BETRIEBSART_PV, self::BETRIEBSART_PEAK],
true
)) {
throw new InvalidArgumentException('Betriebsart muss PV oder Peak sein.');
}
}
/** /**
* @param mixed $prioritaet * @param mixed $prioritaet
*/ */
+214
View File
@@ -0,0 +1,214 @@
<?php
declare(strict_types=1);
namespace Belevo\EnelixEMS;
use InvalidArgumentException;
final class PufferspeicherRegler
{
public const MINIMUM_AUS = 0;
public const MINIMUM_STATISCH = 1;
public const MINIMUM_DIFFERENZ = 2;
/**
* @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 solltemperatur(
float $aussentemperatur,
float $fusspunkt,
float $steigung,
float $minimum,
float $maximum
): float {
if ($steigung < 0.0) {
throw new InvalidArgumentException('HeizkurvenSteigung darf nicht negativ sein.');
}
if ($minimum > $maximum) {
throw new InvalidArgumentException('Heizkurvenminimum darf nicht groesser als das Maximum sein.');
}
$soll = $fusspunkt + $steigung * (20.0 - $aussentemperatur);
return min($maximum, max($minimum, $soll));
}
public static function einschaltschwelle(
float $solltemperatur,
float $hysterese,
int $minimumModus,
float $statischeMindesttemperatur,
float $mindesttemperaturDifferenz
): float {
if ($hysterese <= 0.0) {
throw new InvalidArgumentException('Hysterese muss groesser als 0 sein.');
}
if ($minimumModus === self::MINIMUM_AUS) {
return $solltemperatur - $hysterese;
}
if ($minimumModus === self::MINIMUM_STATISCH) {
return min($solltemperatur, $statischeMindesttemperatur);
}
if ($minimumModus === self::MINIMUM_DIFFERENZ) {
if ($mindesttemperaturDifferenz < 0.0) {
throw new InvalidArgumentException('MindesttemperaturDifferenz darf nicht negativ sein.');
}
return $solltemperatur - $mindesttemperaturDifferenz;
}
throw new InvalidArgumentException('MindesttemperaturModus muss 0, 1 oder 2 sein.');
}
/**
* @param list<array{Stufe: int, Leistung_W: int, KontaktID: int}> $stufen
* @return list<int>
*/
public static function leistungsangebot(
bool $aktiv,
bool $peakbetrieb,
bool $temperaturenGueltig,
float $puffertemperatur,
float $einschaltschwelle,
array $stufen
): array {
if (!$aktiv || $peakbetrieb || !$temperaturenGueltig || $stufen === []) {
return [0];
}
if ($puffertemperatur < $einschaltschwelle) {
return self::leistungswerte($stufen);
}
return [0];
}
public static function fusspunktAusWaermepumpenwert(
float $waermepumpenSolltemperatur,
float $aussentemperatur,
float $steigung
): float {
if ($steigung < 0.0) {
throw new InvalidArgumentException('HeizkurvenSteigung darf nicht negativ sein.');
}
return $waermepumpenSolltemperatur - $steigung * (20.0 - $aussentemperatur);
}
public static function pt1(float $bisher, float $eingang, int $deltaSekunden, int $zeitkonstante): float
{
if ($deltaSekunden <= 0 || $zeitkonstante <= 0) {
throw new InvalidArgumentException('PT1-Zeiten muessen 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('LastwechselSperrzeit darf nicht negativ sein.');
}
if ($letzterLastwechsel <= 0) {
return 0;
}
return max(0, $sperrzeitSekunden - max(0, $jetzt - $letzterLastwechsel));
}
public static function energieKWh(float $leistungW, int $dauerSekunden): float
{
if ($dauerSekunden < 0) {
throw new InvalidArgumentException('Dauer darf nicht negativ sein.');
}
return $leistungW * $dauerSekunden / 3600000.0;
}
/** @param array<mixed> $wert */
private static function istListe(array $wert): bool
{
return $wert === [] || array_keys($wert) === range(0, count($wert) - 1);
}
}
+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 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 * @param array<string, mixed> $daten
*/ */
public function ManagerdatenEmpfangen(array $daten): void; public function ManagerdatenEmpfangen(array $daten): void;
} }
+269
View File
@@ -0,0 +1,269 @@
<?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<int> $leistungswerte
* @return list<int>
*/
public static function leistungsangebot(
bool $peakbetrieb,
float $temperatur,
float $mindesttemperatur,
float $maximaltemperatur,
float $hysterese,
bool $stufeAktiv,
array $leistungswerte
): array {
if ($hysterese < 0.0 || $leistungswerte === []) {
throw new InvalidArgumentException('Hysterese und Leistungswerte sind ungueltig.');
}
$mindestladung = $temperatur < $mindesttemperatur
|| ($temperatur < $mindesttemperatur + $hysterese && $stufeAktiv);
if ($peakbetrieb) {
return $mindestladung ? $leistungswerte : [0];
}
if ($mindestladung) {
return [max($leistungswerte)];
}
if ($temperatur < $maximaltemperatur - $hysterese
|| ($temperatur < $maximaltemperatur && $stufeAktiv)
) {
return $leistungswerte;
}
return [0];
}
/**
* @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);
}
}
+13
View File
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/9.6/phpunit.xsd"
bootstrap="vendor/autoload.php"
colors="true"
failOnWarning="true"
failOnRisky="true">
<testsuites>
<testsuite name="Verbraucher 1-Stufig">
<directory>tests/VerbraucherEinStufig</directory>
</testsuite>
</testsuites>
</phpunit>
+145
View File
@@ -0,0 +1,145 @@
<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
require_once dirname(__DIR__) . '/examples/Demoanlage/bootstrap.php';
final class DemoanlageBootstrapTest extends TestCase
{
public function testEnergieflussEnthaeltAlleAnlagenteile(): void
{
$html = enelixDemoRenderFlow([
'hour' => 12.5,
'pv' => 6500.0,
'base' => 900.0,
'grid' => -800.0,
'boiler1' => 2400,
'boiler2' => 1500,
'dryer' => 900,
'temp1' => 51.2,
'temp2' => 48.7,
'manager' => 'PV',
]);
self::assertStringContainsString('Enelix 2 Energiefluss', $html);
self::assertStringContainsString('Boiler 1', $html);
self::assertStringContainsString('Boiler 2', $html);
self::assertStringContainsString('Entfeuchter', $html);
self::assertStringContainsString('Einspeisung', $html);
self::assertStringContainsString('12:30', $html);
}
public function testDiagrammStelltMehrereZeitreihenDar(): void
{
$html = enelixDemoRenderChart(
'Leistungsverlauf',
[
['name' => 'PV', 'color' => '#f4c542', 'values' => [
['time' => 100, 'value' => 1000.0],
['time' => 200, 'value' => 3000.0],
]],
['name' => 'Netz', 'color' => '#e95d5d', 'values' => [
['time' => 100, 'value' => -500.0],
['time' => 200, 'value' => 500.0],
]],
],
'W',
100,
200
);
self::assertStringContainsString('<svg', $html);
self::assertStringContainsString('Leistungsverlauf', $html);
self::assertStringContainsString('PV', $html);
self::assertStringContainsString('Netz', $html);
self::assertSame(2, substr_count($html, '<polyline'));
}
public function testNativeEnergieflussKnotenHatVollstaendigesSchema(): void
{
self::assertSame([
'VariableConfigType' => 0,
'VariableID' => 12345,
'SecondaryVariableID' => 1,
'Name' => 'PV-Anlage',
'Type' => 'solar',
'Grouped' => false,
'CalculationType' => 0,
'InfoVariableID' => 54321,
'Invert' => false,
], enelixDemoEnergyNode(12345, 'PV-Anlage', 'solar', 54321));
self::assertSame(
1,
enelixDemoEnergyNode(12345, 'PV-Anlage', 'solar')['InfoVariableID']
);
}
public function testNativeDiagrammDatenreiheHatSymconFormat(): void
{
self::assertSame([
'variableID' => 12345,
'fillColor' => 'clear',
'strokeColor' => '#f4c542',
'timeOffset' => 0,
'visible' => true,
'title' => 'PV',
'type' => 'line',
'side' => 'left',
'profile' => '~Power',
], enelixDemoChartDataset(12345, 'PV', '#f4c542', '~Power'));
}
public function testDashboardVerwendetNativeEnergieverteilungUndNeueBoilerlisten(): void
{
$source = file_get_contents(dirname(__DIR__) . '/examples/Demoanlage/bootstrap.php');
self::assertIsString($source);
self::assertStringContainsString("IPS_SetHidden(\$legacyFlow, true);", $source);
self::assertStringContainsString("'EnergieverteilungNativ'", $source);
self::assertStringContainsString(
'enelixDemoConfigureVisualization($root, $energyDistribution);',
$source
);
self::assertStringContainsString('ENELIX_DEMO_DUMMY_MODULE', $source);
self::assertStringContainsString("'Boiler1Liste'", $source);
self::assertStringContainsString("'Boiler2Liste'", $source);
self::assertStringContainsString("'EnergieflussPVkW'", $source);
self::assertStringContainsString("'~Power'", $source);
self::assertStringContainsString("'~Electricity'", $source);
self::assertStringContainsString(
"enelixDemoEnergyNode(\$flowPv, 'PV-Anlage', 'solar')",
$source
);
self::assertStringContainsString(
"enelixDemoEnergyNode(\$flowBase, 'Haus-Grundlast', 'consumer')",
$source
);
self::assertStringContainsString(
"enelixDemoEnergyNode(\$flowDryer, 'Entfeuchter', 'consumer')",
$source
);
self::assertStringContainsString(
"enelixDemoEnsureChart(\$history, 'LeistungsdiagrammNativ'",
$source
);
self::assertStringContainsString('IPS_SetHidden($powerChart, true);', $source);
self::assertStringContainsString(
"enelixDemoLinkInstanceVariables(\$boiler1List, \$boiler1, \$temp1);",
$source
);
self::assertStringContainsString(
"foreach (['Boiler1Link', 'Boiler2Link'] as \$oldLinkIdent)",
$source
);
}
public function testRollbackIstExplizitBestaetigungspflichtig(): void
{
$this->expectException(RuntimeException::class);
$this->expectExceptionMessage('Rollback nur mit');
enelixDemoRemove();
}
}
+83
View File
@@ -0,0 +1,83 @@
<?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'],
['Waermepumpe'],
['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 testPufferspeicherDokumentiertDenImplementiertenStand(): void
{
$inhalt = file_get_contents(__DIR__ . '/../docs/module/Pufferspeicher/README.md');
self::assertNotFalse($inhalt);
self::assertStringContainsString('Status: implementiert', $inhalt);
self::assertStringContainsString('Managerkommunikation', $inhalt);
}
public function testLadestationStandAloneDokumentiertDenImplementiertenStand(): void
{
$inhalt = file_get_contents(__DIR__ . '/../docs/module/Ladestation-Stand-Alone/README.md');
self::assertNotFalse($inhalt);
self::assertStringContainsString('Status: Implementiert', $inhalt);
self::assertStringContainsString('Properties', $inhalt);
self::assertStringContainsString('Fake-HTTP-Transport', $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);
}
}
+171
View File
@@ -0,0 +1,171 @@
<?php
declare(strict_types=1);
namespace Belevo\EnelixEMS\Tests;
use Belevo\EnelixEMS\LadestationAdapter;
use Belevo\EnelixEMS\LadestationTransport;
use PHPUnit\Framework\TestCase;
final class LadestationAdapterTest extends TestCase
{
/**
* @dataProvider statusFaelle
* @param array<string, string> $konfiguration
* @param array{Rohstatus: int, Istleistung_W: float} $erwartet
*/
public function testStatusaufrufUndDekodierungFuerAlleGeraete(
int $geraetetyp,
array $konfiguration,
string $antwort,
string $erwarteteUrl,
array $erwartet
): void {
$aufrufe = [];
$transport = new LadestationTransport(static function (array $anfrage) use (
&$aufrufe,
$antwort
): string {
$aufrufe[] = $anfrage;
return $antwort;
});
$rohstatus = LadestationAdapter::dekodiereStatus(
$geraetetyp,
$transport->statusLesen($geraetetyp, $konfiguration)
);
self::assertSame($erwarteteUrl, $aufrufe[0]['URL']);
self::assertSame('GET', $aufrufe[0]['Methode']);
self::assertSame($erwartet, $rohstatus);
}
/** @return iterable<string, array<mixed>> */
public function statusFaelle(): iterable
{
yield 'go-e alt' => [
LadestationAdapter::GO_E_ALT,
$this->lokaleKonfiguration(),
$this->goEAntwort(2, 1100),
'http://192.0.2.10/mqtt?payload=',
['Rohstatus' => 2, 'Istleistung_W' => 11000.0],
];
yield 'go-e Gemini' => [
LadestationAdapter::GO_E_GEMINI,
$this->lokaleKonfiguration(),
$this->goEAntwort(2, 2300),
'http://192.0.2.10/api/status',
['Rohstatus' => 2, 'Istleistung_W' => 2300.0],
];
yield 'Smart-me Pico' => [
LadestationAdapter::SMART_ME_PICO,
$this->picoKonfiguration(),
json_encode([
'State' => 2,
'ActiveChargingPower' => 11.0,
'MaxAllowedChargingCurrent' => 16,
], JSON_THROW_ON_ERROR),
'https://api.smart-me.com/pico/charging/pico-id',
['Rohstatus' => 2, 'Istleistung_W' => 11000.0],
];
}
/**
* @dataProvider steuerFaelle
* @param array<string, string> $konfiguration
* @param list<array{Methode: string, URL: string}> $erwarteteAufrufe
*/
public function testSteueraufrufeWerdenUeberFakeTransportGeprueft(
int $geraetetyp,
array $konfiguration,
int $strom,
array $erwarteteAufrufe
): void {
$aufrufe = [];
$transport = new LadestationTransport(static function (array $anfrage) use (&$aufrufe): string {
unset($anfrage['Benutzername'], $anfrage['Passwort']);
$aufrufe[] = $anfrage;
return '{}';
});
$transport->ladestromSetzen($geraetetyp, $konfiguration, $strom);
self::assertSame($erwarteteAufrufe, $aufrufe);
}
/** @return iterable<string, array<mixed>> */
public function steuerFaelle(): iterable
{
yield 'go-e alt laden' => [
LadestationAdapter::GO_E_ALT,
$this->lokaleKonfiguration(),
16,
[
['Methode' => 'GET', 'URL' => 'http://192.0.2.10/mqtt?payload=alw=1'],
['Methode' => 'GET', 'URL' => 'http://192.0.2.10/mqtt?payload=amp=16'],
],
];
yield 'go-e Gemini stoppen' => [
LadestationAdapter::GO_E_GEMINI,
$this->lokaleKonfiguration(),
0,
[[
'Methode' => 'GET',
'URL' => 'http://192.0.2.10/api/set?frc=1&sua=false',
]],
];
yield 'Smart-me Pico laden' => [
LadestationAdapter::SMART_ME_PICO,
$this->picoKonfiguration(),
16,
[[
'Methode' => 'POST',
'URL' => 'https://api.smart-me.com/pico/loadmanagementgroup/current/serial-1?current=16000',
]],
];
}
public function testPicoAnfragenVerwendenBasicAuth(): void
{
$anfrage = LadestationAdapter::statusAnfrage(
LadestationAdapter::SMART_ME_PICO,
$this->picoKonfiguration()
);
self::assertSame('api-user', $anfrage['Benutzername']);
self::assertSame(hash('sha256', __FILE__), $anfrage['Passwort']);
}
/** @return array<string, string> */
private function lokaleKonfiguration(): array
{
return [
'Geraeteadresse' => '192.0.2.10',
'GeraeteID' => '',
'Seriennummer' => '',
'Benutzername' => '',
'Passwort' => '',
];
}
/** @return array<string, string> */
private function picoKonfiguration(): array
{
return [
'Geraeteadresse' => '',
'GeraeteID' => 'pico-id',
'Seriennummer' => 'serial-1',
'Benutzername' => 'api-user',
'Passwort' => hash('sha256', __FILE__),
];
}
private function goEAntwort(int $status, float $leistung): string
{
$nrg = array_fill(0, 12, 0);
$nrg[11] = $leistung;
return json_encode(['car' => $status, 'nrg' => $nrg], JSON_THROW_ON_ERROR);
}
}
+67
View File
@@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
namespace Belevo\EnelixEMS\Tests;
use Belevo\EnelixEMS\LadestationRegler;
use PHPUnit\Framework\TestCase;
final class LadestationReglerTest extends TestCase
{
public function testFahrzeugUndDreiPhasenWerdenWieInEnelixEinsErkannt(): void
{
$status = LadestationRegler::normalisiereStatus([
'Rohstatus' => 2,
'Istleistung_W' => 11000.0,
], false);
self::assertTrue($status['FahrzeugVerbunden']);
self::assertFalse($status['FahrzeugGeladen']);
self::assertSame(3, $status['Fahrzeugstatus']);
self::assertSame(LadestationRegler::PHASEN_DREI, $status['Phasenzahl']);
}
public function testEinphasigesFahrzeugWirdUnterhalb7500WErkannt(): void
{
$status = LadestationRegler::normalisiereStatus([
'Rohstatus' => 2,
'Istleistung_W' => 2300.0,
], false);
self::assertSame(LadestationRegler::PHASEN_EIN, $status['Phasenzahl']);
self::assertEqualsWithDelta(10.0, $status['Ladestrom_A'], 0.01);
}
public function testGeladenWirdNachVerbindungUnterSechsAmpereErkannt(): void
{
$status = LadestationRegler::normalisiereStatus([
'Rohstatus' => 2,
'Istleistung_W' => 0.0,
], true);
self::assertTrue($status['FahrzeugGeladen']);
self::assertSame(4, $status['Fahrzeugstatus']);
}
public function testBetriebsartUndSolarladenBestimmenDasLeistungsangebot(): void
{
$solarPV = LadestationRegler::leistungsangebot(true, true, true, false, 1, 16, true, false);
$normalPV = LadestationRegler::leistungsangebot(true, true, true, false, 1, 16, false, false);
$solarPeak = LadestationRegler::leistungsangebot(true, true, true, false, 1, 16, true, true);
$normalPeak = LadestationRegler::leistungsangebot(true, true, true, false, 1, 16, false, true);
self::assertSame(0, $solarPV[0]);
self::assertCount(12, $solarPV);
self::assertSame([3850], $normalPV);
self::assertSame([0], $solarPeak);
self::assertSame($solarPV, $normalPeak);
}
public function testPeakKannJedesFahrzeugAufNullSetzen(): void
{
self::assertSame(0, LadestationRegler::stromFuerLeistung(0, 3));
self::assertSame(6, LadestationRegler::stromFuerLeistung(4150, 3));
self::assertSame(16, LadestationRegler::stromFuerLeistung(11000, 3));
}
}
@@ -0,0 +1,73 @@
<?php
declare(strict_types=1);
namespace Belevo\EnelixEMS\Tests;
use PHPUnit\Framework\TestCase;
final class LadestationStandAloneModulstrukturTest extends TestCase
{
private const MODULPFAD = __DIR__ . '/../LadestationStandAlone/';
public function testModulIstVollstaendigUndMetadatenSindGueltig(): void
{
foreach (['module.json', 'form.json', 'module.php'] as $datei) {
self::assertFileExists(self::MODULPFAD . $datei);
}
$modul = json_decode(
(string) file_get_contents(self::MODULPFAD . 'module.json'),
true,
512,
JSON_THROW_ON_ERROR
);
self::assertSame('LadestationStandAlone', $modul['name']);
self::assertSame('8.0', json_decode(
(string) file_get_contents(__DIR__ . '/../library.json'),
true,
512,
JSON_THROW_ON_ERROR
)['compatibility']['version']);
}
public function testGemeinsamerVerbrauchervertragWirdVerwendet(): void
{
$modul = (string) file_get_contents(self::MODULPFAD . 'module.php');
self::assertStringContainsString('implements VerbraucherSchnittstelle', $modul);
self::assertStringContainsString('use VerbraucherBasisTrait;', $modul);
self::assertStringContainsString('ManagerdatenEmpfangen(array $daten)', $modul);
self::assertStringContainsString('Nachrichtenvertrag::pruefeVerbraucherdaten', $modul);
self::assertStringNotContainsString("RegisterVariableString('PowerSteps'", $modul);
}
public function testAlleDreiStandAloneGeraeteUndSichereZugangsdatenSindKonfiguriert(): void
{
$form = json_decode(
(string) file_get_contents(self::MODULPFAD . 'form.json'),
true,
512,
JSON_THROW_ON_ERROR
);
$json = json_encode($form, JSON_THROW_ON_ERROR);
self::assertStringContainsString('go-e Charger (alte API)', $json);
self::assertStringContainsString('go-e Charger Gemini', $json);
self::assertStringContainsString('Gemini flex', $json);
self::assertStringContainsString('smart-me Pico', $json);
self::assertStringContainsString('PasswordTextBox', $json);
self::assertStringNotContainsString('Testmodus', $json);
}
public function testVisualisierungsbuttonsLoggingUndDiagnoseSindVorhanden(): void
{
$modul = (string) file_get_contents(self::MODULPFAD . 'module.php');
self::assertStringContainsString("EnableAction('Ladefreigabe')", $modul);
self::assertStringContainsString("EnableAction('Solarladen')", $modul);
self::assertStringContainsString("ReadPropertyBoolean('LoggingEin')", $modul);
self::assertStringContainsString("ReadPropertyBoolean('EinstellungenInVisu')", $modul);
self::assertStringContainsString('UnregisterVariable($ident)', $modul);
}
}
+1
View File
@@ -15,6 +15,7 @@ final class LibraryMetadataTest extends TestCase
$daten = json_decode($inhalt, true, 512, JSON_THROW_ON_ERROR); $daten = json_decode($inhalt, true, 512, JSON_THROW_ON_ERROR);
self::assertSame('Enelix EMS', $daten['name']); self::assertSame('Enelix EMS', $daten['name']);
self::assertSame('Enelix', $daten['author']);
self::assertSame('8.0', $daten['compatibility']['version']); self::assertSame('8.0', $daten['compatibility']['version']);
self::assertMatchesRegularExpression('/^\{[0-9A-F-]{36}\}$/', $daten['id']); self::assertMatchesRegularExpression('/^\{[0-9A-F-]{36}\}$/', $daten['id']);
} }
+143
View File
@@ -0,0 +1,143 @@
<?php
declare(strict_types=1);
namespace Belevo\EnelixEMS\Tests;
use Belevo\EnelixEMS\Lizenzpruefung;
use DateTimeImmutable;
use InvalidArgumentException;
use PHPUnit\Framework\TestCase;
final class LizenzpruefungTest extends TestCase
{
private const INSTALLATION_ID = '123e4567-e89b-42d3-a456-426614174000';
public function testStandardLizenzGibtGrundregelungFrei(): void
{
Lizenzpruefung::pruefeLease(
$this->lease('manager_standard'),
self::INSTALLATION_ID,
0,
$this->zeit('2026-09-19T12:00:00Z')
);
self::assertTrue(true);
}
public function testPeakLizenzGibtLastspitzenmodusFrei(): void
{
Lizenzpruefung::pruefeLease(
$this->lease('manager_peak'),
self::INSTALLATION_ID,
2,
$this->zeit('2026-09-19T12:00:00Z')
);
self::assertTrue(true);
}
public function testStandardLizenzSperrtLastspitzenmodus(): void
{
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage('Peak Shaving ist nicht lizenziert.');
Lizenzpruefung::pruefeLease(
$this->lease('manager_standard'),
self::INSTALLATION_ID,
1,
$this->zeit('2026-09-19T12:00:00Z')
);
}
public function testAbgelaufeneOfflineFreigabeWirdAbgelehnt(): void
{
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage('Offline-Freigabe ist abgelaufen.');
Lizenzpruefung::pruefeLease(
$this->lease('manager_peak'),
self::INSTALLATION_ID,
0,
$this->zeit('2026-10-03T12:00:00Z')
);
}
public function testAndereInstallationWirdAbgelehnt(): void
{
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage('anderen Installation');
Lizenzpruefung::pruefeLease(
$this->lease('manager_standard'),
'223e4567-e89b-42d3-a456-426614174000',
0,
$this->zeit('2026-09-19T12:00:00Z')
);
}
public function testErneuerungWirdAbRefreshAfterFaellig(): void
{
$lease = $this->lease('manager_standard');
self::assertFalse(Lizenzpruefung::erneuerungFaellig(
$lease,
$this->zeit('2026-09-20T11:59:59Z')
));
self::assertTrue(Lizenzpruefung::erneuerungFaellig(
$lease,
$this->zeit('2026-09-20T12:00:00Z')
));
}
public function testLeaseOhneManagerBerechtigungWirdAbgelehnt(): void
{
$lease = $this->lease('consumer_single');
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage('Manager-Berechtigung fehlt.');
Lizenzpruefung::pruefeLease(
$lease,
self::INSTALLATION_ID,
0,
$this->zeit('2026-09-19T12:00:00Z')
);
}
public function testLizenzcodeWirdWieAufDemServerNormalisiert(): void
{
self::assertSame(
'ENX23456789ABCDEFGH',
Lizenzpruefung::normalisiereCode('enx-2345-6789-abcd-efgh')
);
self::assertTrue(Lizenzpruefung::istLizenzcodeGueltig('ENX-2345-6789-ABCD-EFGH'));
self::assertFalse(Lizenzpruefung::istLizenzcodeGueltig('ENX-OOOO-OOOO-OOOO-OOOO'));
}
/**
* @return array<string, mixed>
*/
private function lease(string $sku): array
{
return [
'status' => 'active',
'development' => true,
'installationId' => self::INSTALLATION_ID,
'orderId' => '323e4567-e89b-42d3-a456-426614174000',
'entitlements' => [[
'sku' => $sku,
'catalog_key' => $sku,
'quantity' => 1,
'status' => 'active',
]],
'issuedAt' => '2026-09-19T12:00:00.000Z',
'refreshAfter' => '2026-09-20T12:00:00.000Z',
'offlineUntil' => '2026-10-03T12:00:00.000Z',
];
}
private function zeit(string $wert): int
{
return (new DateTimeImmutable($wert))->getTimestamp();
}
}
+142
View File
@@ -0,0 +1,142 @@
<?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 | JSON_UNESCAPED_SLASHES);
foreach ([
'NetzleistungVariableID',
'VerbraucherZuordnung',
'AutomatischeVerbraucherZuordnung',
'Lastspitzenmodus',
'Monatsgrenzen',
'SollwertSolarladen',
'KeepAlive',
'VerbraucherTimeout',
'MesswertMaxAlter',
'Umschaltdifferenz',
'DiagnosevariablenAnzeigen',
'LoggingEin',
'Lizenzcode',
'LizenzPruefen',
'LizenzInstallationID',
'LizenzInformation',
'VerbraucherAktualisieren',
] as $property) {
self::assertStringContainsString($property, $json);
}
foreach (['Regelintervall', 'Vorgabeintervall', 'UmschaltschwelleUeberschuss',
'UmschaltschwelleLastspitze', 'Optionale obere Anschluesse', 'LizenzAnschluss'] 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 testManagerBindetDieLizenzierungFailClosedAn(): void
{
$inhalt = (string) file_get_contents(__DIR__ . '/../Manager/module.php');
self::assertFileExists(__DIR__ . '/../libs/Lizenzpruefung.php');
self::assertStringContainsString(
"RegisterPropertyString('Lizenzcode', '')",
$inhalt
);
self::assertStringContainsString(
"https://license.enelix.ch/api/v1/licenses/activate",
$inhalt
);
self::assertStringContainsString("RegisterAttributeString('LizenzInstallationID'", $inhalt);
self::assertStringContainsString("RegisterAttributeString('LizenzLease'", $inhalt);
self::assertStringContainsString("RegisterAttributeString('LizenzcodeHash'", $inhalt);
self::assertStringContainsString('STATUS_LIZENZ_UNGUELTIG', $inhalt);
self::assertStringContainsString('aktualisiereLizenzfreigabe(', $inhalt);
self::assertStringNotContainsString("'publicKey' =>", $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);
}
}
+188
View File
@@ -0,0 +1,188 @@
<?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,
],
'Betriebsart' => Nachrichtenvertrag::BETRIEBSART_PV,
'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',
]],
],
];
}
}
+17
View File
@@ -14,12 +14,28 @@ final class NachrichtenvertragTest extends TestCase
{ {
Nachrichtenvertrag::pruefeManagerdaten([ Nachrichtenvertrag::pruefeManagerdaten([
'Kopf' => $this->kopf(10001, 20001), 'Kopf' => $this->kopf(10001, 20001),
'Betriebsart' => Nachrichtenvertrag::BETRIEBSART_PV,
'Sollleistung_W' => 1501, 'Sollleistung_W' => 1501,
]); ]);
Nachrichtenvertrag::pruefeManagerdaten([
'Kopf' => $this->kopf(10001, 20001),
'Betriebsart' => Nachrichtenvertrag::BETRIEBSART_PEAK,
'Sollleistung_W' => null,
]);
self::assertTrue(true); self::assertTrue(true);
} }
public function testUngueltigeBetriebsartWirdAbgelehnt(): void
{
$daten = $this->verbraucherdaten();
$daten['Betriebsart'] = 'Unbekannt';
$this->expectException(InvalidArgumentException::class);
Nachrichtenvertrag::pruefeVerbraucherdaten($daten);
}
public function testGueltigeVerbraucherdatenMitEinzelwertenUndBereichenWerdenAkzeptiert(): void public function testGueltigeVerbraucherdatenMitEinzelwertenUndBereichenWerdenAkzeptiert(): void
{ {
Nachrichtenvertrag::pruefeVerbraucherdaten($this->verbraucherdaten()); Nachrichtenvertrag::pruefeVerbraucherdaten($this->verbraucherdaten());
@@ -92,6 +108,7 @@ final class NachrichtenvertragTest extends TestCase
{ {
return [ return [
'Kopf' => $this->kopf(20001, 10001), 'Kopf' => $this->kopf(20001, 10001),
'Betriebsart' => Nachrichtenvertrag::BETRIEBSART_PEAK,
'PrioritaetPV' => 0, 'PrioritaetPV' => 0,
'PrioritaetPeak' => 12, 'PrioritaetPeak' => 12,
'Leistungswerte_W' => [ 'Leistungswerte_W' => [
+111
View File
@@ -0,0 +1,111 @@
<?php
declare(strict_types=1);
namespace Belevo\EnelixEMS\Tests;
use PHPUnit\Framework\TestCase;
final class PufferspeicherModulstrukturTest extends TestCase
{
private const MODULPFAD = __DIR__ . '/../Pufferspeicher/';
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('VerbraucherPufferspeicher', $metadaten['name']);
self::assertSame('Enelix', $metadaten['vendor']);
self::assertSame(['Pufferspeicher'], $metadaten['aliases']);
self::assertSame(3, $metadaten['type']);
}
public function testPropertiesSindRegistriertUndInGewuenschterReihenfolgeImFormular(): 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]));
$formularText = (string) file_get_contents(self::MODULPFAD . 'form.json');
foreach ($properties as $property) {
self::assertStringContainsString('"name": "' . $property . '"', $formularText, $property);
}
$formular = json_decode($formularText, true, 512, JSON_THROW_ON_ERROR);
self::assertSame(
[
'Manager und Zeitverhalten',
'Pufferspeicher',
'Heizkurve',
'Erweiterte Einstellungen',
],
array_column($formular['elements'], 'caption')
);
}
public function testModulVerwendetVertrag40UndBetriebsartabhaengigesAngebot(): void
{
$modul = (string) file_get_contents(self::MODULPFAD . 'module.php');
self::assertStringContainsString('implements VerbraucherSchnittstelle', $modul);
self::assertStringContainsString('ManagerdatenEmpfangen(array $daten)', $modul);
self::assertStringContainsString("['Betriebsart']", $modul);
self::assertStringContainsString('BETRIEBSART_PEAK', $modul);
self::assertStringContainsString("'Betriebsart' =>", $modul);
self::assertStringNotContainsString("RegisterVariableString('PowerSteps'", $modul);
self::assertStringNotContainsString("RegisterPropertyInteger('ManagerID'", $modul);
}
public function testEinAusLoggingVariablenUndWaermepumpenaktionSindEnthalten(): void
{
$modul = (string) file_get_contents(self::MODULPFAD . 'module.php');
$formular = (string) file_get_contents(self::MODULPFAD . 'form.json');
self::assertStringContainsString("case 'Aktiv':", $modul);
self::assertStringContainsString("ReadPropertyBoolean('LoggingEin')", $modul);
foreach ([
'Puffertemperatur',
'Aussentemperatur',
'Solltemperatur',
'Heizbedarf',
] as $ident) {
self::assertStringContainsString("RegisterVariable", $modul);
self::assertStringContainsString("'" . $ident . "'", $modul);
}
self::assertStringContainsString('HeizkurveVonWaermepumpeUebernehmen', $modul);
self::assertStringContainsString('Wert von Waermepumpe uebernehmen', $formular);
}
public function testSchaltfolgeIstBreakBeforeMake(): void
{
$modul = (string) file_get_contents(self::MODULPFAD . 'module.php');
$aus = strpos($modul, "RequestAction(\$stufe['KontaktID'], false)");
$ein = strpos($modul, "RequestAction(\$stufe['KontaktID'], true)");
self::assertIsInt($aus);
self::assertIsInt($ein);
self::assertLessThan($ein, $aus);
}
public function testManagerKenntDieModulIDGenauEinmal(): void
{
$manager = (string) file_get_contents(__DIR__ . '/../Manager/module.php');
self::assertSame(1, substr_count(
$manager,
'{C92D5EEF-9632-47A5-9659-4B02BF40FBE9}'
));
}
}
+154
View File
@@ -0,0 +1,154 @@
<?php
declare(strict_types=1);
namespace Belevo\EnelixEMS\Tests;
use Belevo\EnelixEMS\PufferspeicherRegler;
use PHPUnit\Framework\TestCase;
final class PufferspeicherReglerTest extends TestCase
{
/** @return list<array{Stufe: int, Leistung_W: int, KontaktID: int}> */
private function stufen(): array
{
return [
['Stufe' => 1, 'Leistung_W' => 1000, 'KontaktID' => 101],
['Stufe' => 2, 'Leistung_W' => 2000, 'KontaktID' => 102],
];
}
public function testHeizkurveWirdBerechnetUndBegrenzt(): void
{
self::assertSame(
50.0,
PufferspeicherRegler::solltemperatur(5.0, 35.0, 1.0, 20.0, 80.0)
);
self::assertSame(
80.0,
PufferspeicherRegler::solltemperatur(-100.0, 35.0, 1.0, 20.0, 80.0)
);
self::assertSame(
20.0,
PufferspeicherRegler::solltemperatur(100.0, 35.0, 1.0, 20.0, 80.0)
);
}
public function testUnterErrechneterEinschaltschwelleWerdenAlleStufenAngeboten(): void
{
self::assertSame(
[0, 1000, 2000],
PufferspeicherRegler::leistungsangebot(
true,
false,
true,
44.9,
45.0,
$this->stufen()
)
);
}
public function testInHystereseUndDarueberWirdNurNullAngeboten(): void
{
self::assertSame(
[0],
PufferspeicherRegler::leistungsangebot(
true,
false,
true,
45.0,
45.0,
$this->stufen()
)
);
self::assertSame(
[0],
PufferspeicherRegler::leistungsangebot(
true,
false,
true,
51.0,
45.0,
$this->stufen()
)
);
}
public function testPeakbetriebBietetUnabhaengigVonDerTemperaturNurNull(): void
{
foreach ([-20.0, 44.9, 45.0, 100.0] as $temperatur) {
self::assertSame(
[0],
PufferspeicherRegler::leistungsangebot(
true,
true,
true,
$temperatur,
45.0,
$this->stufen()
)
);
}
}
public function testAusgeschaltetWirdImmerNurNullAngeboten(): void
{
self::assertSame(
[0],
PufferspeicherRegler::leistungsangebot(
false,
false,
true,
-20.0,
45.0,
$this->stufen()
)
);
}
public function testMindesttemperaturIstOptionalStatischOderDifferenzbasiert(): void
{
self::assertSame(
45.0,
PufferspeicherRegler::einschaltschwelle(
50.0,
5.0,
PufferspeicherRegler::MINIMUM_AUS,
30.0,
10.0
)
);
self::assertSame(
42.0,
PufferspeicherRegler::einschaltschwelle(
50.0,
5.0,
PufferspeicherRegler::MINIMUM_STATISCH,
42.0,
10.0
)
);
self::assertSame(
43.0,
PufferspeicherRegler::einschaltschwelle(
50.0,
5.0,
PufferspeicherRegler::MINIMUM_DIFFERENZ,
30.0,
7.0
)
);
}
public function testWaermepumpenwertKalibriertFusspunktAmAktuellenAussenwert(): void
{
$fusspunkt = PufferspeicherRegler::fusspunktAusWaermepumpenwert(50.0, 5.0, 1.0);
self::assertSame(35.0, $fusspunkt);
self::assertSame(
50.0,
PufferspeicherRegler::solltemperatur(5.0, $fusspunkt, 1.0, 0.0, 100.0)
);
}
}
+314
View File
@@ -0,0 +1,314 @@
<?php
declare(strict_types=1);
namespace Belevo\EnelixEMS\SymconTest;
use RuntimeException;
use Throwable;
final class TestContext
{
private string $repository;
private string $suite;
private string $runId;
private int $rootId;
/** @var list<array{name: string, status: string, duration_ms: int, message: string}> */
private array $cases = [];
/** @var list<string> */
private array $cleanupErrors = [];
/** @var list<string> */
private array $profilesToDelete = [];
public function __construct(string $repository, string $suite)
{
$this->repository = $repository;
$this->suite = $suite;
$this->runId = date('Ymd-His') . '-' . strtoupper(bin2hex(random_bytes(4)));
$this->rootId = IPS_CreateCategory();
IPS_SetParent($this->rootId, 0);
IPS_SetIdent($this->rootId, 'ENELIX_TEST_' . str_replace('-', '_', $this->runId));
IPS_SetName($this->rootId, 'Enelix Test ' . $suite . ' ' . $this->runId);
IPS_SetInfo($this->rootId, 'Temporär; wird durch den Enelix-Test-Runner entfernt.');
}
public function rootId(): int
{
return $this->rootId;
}
public function runId(): string
{
return $this->runId;
}
public function runCase(string $name, callable $test): void
{
$started = microtime(true);
try {
$test($this);
$this->cases[] = $this->caseResult($name, 'passed', $started, '');
} catch (Throwable $error) {
$this->cases[] = $this->caseResult($name, 'failed', $started, $error->getMessage());
}
}
public function fail(string $message): void
{
throw new RuntimeException($message);
}
public function assertTrue(bool $condition, string $message = 'Bedingung ist nicht erfüllt.'): void
{
if (!$condition) {
$this->fail($message);
}
}
public function assertSame($expected, $actual, string $message = ''): void
{
if ($expected === $actual) {
return;
}
$prefix = $message === '' ? '' : $message . ' ';
$this->fail(
$prefix . 'Erwartet: ' . $this->export($expected) . '; erhalten: ' . $this->export($actual)
);
}
public function assertEquals(float $expected, float $actual, float $delta, string $message = ''): void
{
if (abs($expected - $actual) <= $delta) {
return;
}
$prefix = $message === '' ? '' : $message . ' ';
$this->fail($prefix . "Erwartet: {$expected} ± {$delta}; erhalten: {$actual}");
}
public function assertInstanceStatus(int $instanceId, int $expected = 102): void
{
$this->assertTrue(IPS_InstanceExists($instanceId), 'Instanz existiert nicht.');
$status = (int) IPS_GetInstance($instanceId)['InstanceStatus'];
$this->assertSame($expected, $status, 'Unerwarteter Instanzstatus.');
}
public function objectByIdent(string $ident, int $parentId): int
{
$objectId = @IPS_GetObjectIDByIdent($ident, $parentId);
if ($objectId === false) {
$this->fail("Objekt {$ident} unter {$parentId} wurde nicht angelegt.");
}
return (int) $objectId;
}
public function createCategory(string $name, ?int $parentId = null): int
{
$id = IPS_CreateCategory();
IPS_SetParent($id, $parentId ?? $this->rootId);
IPS_SetName($id, $name);
return $id;
}
public function createVariable(
int $type,
string $name,
$value,
?int $parentId = null,
string $profile = ''
): int {
$id = IPS_CreateVariable($type);
IPS_SetParent($id, $parentId ?? $this->rootId);
IPS_SetName($id, $name);
if ($profile !== '' && IPS_VariableProfileExists($profile)) {
IPS_SetVariableCustomProfile($id, $profile);
}
SetValue($id, $value);
return $id;
}
public function createActionVariable(string $name, bool $value = false): int
{
$variableId = $this->createVariable(0, $name, $value);
$scriptId = IPS_CreateScript(0);
IPS_SetParent($scriptId, $this->rootId);
IPS_SetName($scriptId, $name . ' Aktion');
IPS_SetHidden($scriptId, true);
IPS_SetScriptContent($scriptId, <<<'PHP'
<?php
SetValue((int) $_IPS['VARIABLE'], (bool) $_IPS['VALUE']);
PHP
);
IPS_SetVariableCustomAction($variableId, $scriptId);
return $variableId;
}
/** @param array<string, bool|int|float|string> $properties */
public function createInstance(string $moduleId, string $name, array $properties = []): int
{
if (!in_array($moduleId, IPS_GetModuleList(), true)) {
$this->fail("Modul {$moduleId} ist in IP-Symcon nicht verfügbar.");
}
$id = IPS_CreateInstance($moduleId);
IPS_SetParent($id, $this->rootId);
IPS_SetName($id, $name);
foreach ($properties as $property => $value) {
IPS_SetProperty($id, $property, $value);
}
IPS_ApplyChanges($id);
return $id;
}
/** @param array<string, bool|int|float|string> $properties */
public function configureInstance(int $instanceId, array $properties): void
{
foreach ($properties as $property => $value) {
IPS_SetProperty($instanceId, $property, $value);
}
IPS_ApplyChanges($instanceId);
}
public function trackVariableProfile(string $name, bool $existedBefore): void
{
if (!$existedBefore && IPS_VariableProfileExists($name)) {
$this->profilesToDelete[] = $name;
}
}
public function waitUntil(callable $condition, int $timeoutMs = 2000, int $intervalMs = 50): void
{
$deadline = microtime(true) + ($timeoutMs / 1000);
do {
if ($condition()) {
return;
}
IPS_Sleep($intervalMs);
} while (microtime(true) < $deadline);
$this->fail("Bedingung wurde nach {$timeoutMs} ms nicht erfüllt.");
}
public function cleanup(): void
{
if (IPS_ObjectExists($this->rootId)) {
try {
$this->disconnectInstances($this->rootId);
$this->deleteObjectTree($this->rootId);
} catch (Throwable $error) {
$this->cleanupErrors[] = $error->getMessage();
}
}
foreach (array_unique($this->profilesToDelete) as $profile) {
try {
if (IPS_VariableProfileExists($profile)) {
IPS_DeleteVariableProfile($profile);
}
} catch (Throwable $error) {
$this->cleanupErrors[] = "Profil {$profile}: " . $error->getMessage();
}
}
if (IPS_ObjectExists($this->rootId)) {
$this->cleanupErrors[] = 'Temporäre Testkategorie konnte nicht vollständig entfernt werden.';
}
}
/** @return array<string, mixed> */
public function report(): array
{
$failed = count(array_filter(
$this->cases,
static fn (array $case): bool => $case['status'] === 'failed'
));
$failed += count($this->cleanupErrors);
return [
'repository' => $this->repository,
'suite' => $this->suite,
'run_id' => $this->runId,
'passed' => $failed === 0,
'tests' => count($this->cases) + count($this->cleanupErrors),
'failures' => $failed,
'cases' => $this->cases,
'cleanup_errors' => $this->cleanupErrors,
];
}
/** @return array{name: string, status: string, duration_ms: int, message: string} */
private function caseResult(string $name, string $status, float $started, string $message): array
{
return [
'name' => $name,
'status' => $status,
'duration_ms' => (int) round((microtime(true) - $started) * 1000),
'message' => $message,
];
}
private function export($value): string
{
return str_replace(["\r", "\n"], ['\\r', '\\n'], var_export($value, true));
}
private function disconnectInstances(int $objectId): void
{
if (!IPS_ObjectExists($objectId)) {
return;
}
foreach (IPS_GetChildrenIDs($objectId) as $childId) {
$this->disconnectInstances((int) $childId);
}
if ((int) IPS_GetObject($objectId)['ObjectType'] !== 1) {
return;
}
$connectionId = (int) IPS_GetInstance($objectId)['ConnectionID'];
if ($connectionId > 0) {
IPS_DisconnectInstance($objectId);
}
}
private function deleteObjectTree(int $objectId): void
{
if (!IPS_ObjectExists($objectId)) {
return;
}
foreach (array_reverse(IPS_GetChildrenIDs($objectId)) as $childId) {
$this->deleteObjectTree((int) $childId);
}
$type = (int) IPS_GetObject($objectId)['ObjectType'];
switch ($type) {
case 0:
IPS_DeleteCategory($objectId);
return;
case 1:
IPS_DeleteInstance($objectId);
return;
case 2:
IPS_DeleteVariable($objectId);
return;
case 3:
IPS_DeleteScript($objectId, true);
return;
case 4:
IPS_DeleteEvent($objectId);
return;
case 5:
IPS_DeleteMedia($objectId, true);
return;
case 6:
IPS_DeleteLink($objectId);
return;
}
throw new RuntimeException("Unbekannter Objekttyp {$type} bei Objekt {$objectId}.");
}
}
+199
View File
@@ -0,0 +1,199 @@
<?php
declare(strict_types=1);
namespace Belevo\EnelixEMS\SymconTest;
use InvalidArgumentException;
use RuntimeException;
final class TestRunner
{
private string $repository;
private string $manifestPath;
public function __construct(string $repository, string $manifestPath)
{
$this->repository = $repository;
$this->manifestPath = $manifestPath;
}
/**
* @param list<string>|string $selection
* @return array{report: array<string, mixed>, console: string, junit: string}
*/
public function run(string $mode = 'all', $selection = []): array
{
$definitions = require $this->manifestPath;
if (!is_array($definitions) || $definitions === []) {
throw new RuntimeException('Das Symcon-Testmanifest ist leer oder ungültig.');
}
$selected = $this->selectDefinitions($definitions, $mode, $selection);
$started = microtime(true);
$moduleReports = [];
foreach ($selected as $name => $definition) {
$context = new TestContext($this->repository, (string) $name);
try {
$context->runCase('IP-Symcon 8.x ist aktiv', function (TestContext $test): void {
$version = IPS_GetKernelVersion();
$test->assertTrue(
version_compare($version, '8.0', '>=') && version_compare($version, '9.0', '<'),
'Erwartet wird IP-Symcon 8.x, aktiv ist ' . $version . '.'
);
});
$context->runCase('Modultest ausführen', function (TestContext $test) use ($definition): void {
$file = $definition['file'] ?? null;
if (!is_string($file) || !is_file($file)) {
$test->fail('Testskript wurde nicht gefunden: ' . (string) $file);
}
$moduleTest = require $file;
if (!is_callable($moduleTest)) {
$test->fail('Testskript muss eine aufrufbare Funktion zurückgeben.');
}
$moduleTest($test);
});
} finally {
$context->cleanup();
}
$moduleReports[] = $context->report();
}
$tests = array_sum(array_column($moduleReports, 'tests'));
$failures = array_sum(array_column($moduleReports, 'failures'));
$report = [
'schema_version' => 1,
'repository' => $this->repository,
'mode' => $mode,
'kernel_version' => IPS_GetKernelVersion(),
'started_at' => date(DATE_ATOM, (int) $started),
'duration_ms' => (int) round((microtime(true) - $started) * 1000),
'passed' => $failures === 0,
'modules' => count($moduleReports),
'tests' => $tests,
'failures' => $failures,
'results' => $moduleReports,
];
$console = self::toConsole($report);
IPS_LogMessage($this->repository . ' Tests', $console);
return [
'report' => $report,
'console' => $console,
'junit' => self::toJunit($report),
];
}
/** @param array<string, array<string, mixed>> $definitions */
private function selectDefinitions(array $definitions, string $mode, $selection): array
{
if (!in_array($mode, ['all', 'single', 'affected'], true)) {
throw new InvalidArgumentException('Modus muss all, single oder affected sein.');
}
if ($mode === 'all') {
return $definitions;
}
$names = is_string($selection) ? [$selection] : $selection;
if (!is_array($names) || $names === []) {
throw new InvalidArgumentException("Für {$mode} muss mindestens ein Modul angegeben werden.");
}
$selected = [];
foreach (array_values(array_unique($names)) as $name) {
if (!is_string($name) || !isset($definitions[$name])) {
throw new InvalidArgumentException('Unbekanntes Testmodul: ' . (string) $name);
}
$selected[$name] = $definitions[$name];
}
return $selected;
}
/** @param array<string, mixed> $report */
public static function toConsole(array $report): string
{
$state = $report['passed'] ? 'BESTANDEN' : 'FEHLGESCHLAGEN';
$lines = [
sprintf(
'%s %s: %s (%d Module, %d Tests, %d Fehler, %d ms)',
$report['repository'],
strtoupper((string) $report['mode']),
$state,
$report['modules'],
$report['tests'],
$report['failures'],
$report['duration_ms']
),
];
foreach ($report['results'] as $module) {
$lines[] = sprintf(
'%s %s (%d Tests, %d Fehler)',
$module['passed'] ? '[OK]' : '[FEHLER]',
$module['suite'],
$module['tests'],
$module['failures']
);
foreach ($module['cases'] as $case) {
if ($case['status'] === 'failed') {
$lines[] = ' - ' . $case['name'] . ': ' . $case['message'];
}
}
foreach ($module['cleanup_errors'] as $error) {
$lines[] = ' - Cleanup: ' . $error;
}
}
return implode("\n", $lines);
}
/** @param array<string, mixed> $report */
public static function toJunit(array $report): string
{
$attributes = sprintf(
'name="%s" tests="%d" failures="%d" time="%.3f"',
self::xml((string) $report['repository']),
$report['tests'],
$report['failures'],
$report['duration_ms'] / 1000
);
$xml = ['<?xml version="1.0" encoding="UTF-8"?>', '<testsuite ' . $attributes . '>'];
foreach ($report['results'] as $module) {
foreach ($module['cases'] as $case) {
$xml[] = sprintf(
' <testcase classname="%s.%s" name="%s" time="%.3f">',
self::xml((string) $report['repository']),
self::xml((string) $module['suite']),
self::xml((string) $case['name']),
$case['duration_ms'] / 1000
);
if ($case['status'] === 'failed') {
$xml[] = ' <failure message="' . self::xml((string) $case['message']) . '"/>';
}
$xml[] = ' </testcase>';
}
foreach ($module['cleanup_errors'] as $index => $error) {
$xml[] = sprintf(
' <testcase classname="%s.%s" name="Cleanup %d">',
self::xml((string) $report['repository']),
self::xml((string) $module['suite']),
$index + 1
);
$xml[] = ' <failure message="' . self::xml((string) $error) . '"/>';
$xml[] = ' </testcase>';
}
}
$xml[] = '</testsuite>';
return implode("\n", $xml);
}
private static function xml(string $value): string
{
return htmlspecialchars($value, ENT_QUOTES | ENT_XML1, 'UTF-8');
}
}
+33
View File
@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
$manifest = require dirname(__DIR__) . '/manifest.php';
$paths = array_slice($argv, 1);
$shared = [
'library.json',
'composer.json',
'tests/Symcon/TestContext.php',
'tests/Symcon/TestRunner.php',
'tests/Symcon/bootstrap.php',
'tests/Symcon/manifest.php',
];
$selected = [];
foreach ($paths as $path) {
$path = ltrim(str_replace('\\', '/', $path), './');
if (in_array($path, $shared, true)) {
$selected = array_keys($manifest);
break;
}
foreach ($manifest as $name => $definition) {
foreach ($definition['paths'] as $pattern) {
if (fnmatch($pattern, $path)) {
$selected[] = $name;
break;
}
}
}
}
echo json_encode(array_values(array_unique($selected)), JSON_THROW_ON_ERROR) . PHP_EOL;
+73
View File
@@ -0,0 +1,73 @@
#!/usr/bin/env bash
set -euo pipefail
mode="${1:-all}"
selection="${2:-}"
url="${ENELIX_SYMCON_URL:-http://127.0.0.1:3777/api/}"
script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
repository_root="$(cd -- "$script_dir/../../.." && pwd)"
output_dir="${ENELIX_TEST_OUTPUT_DIR:-$repository_root/build/symcon-tests}"
case "$mode" in
all|single|affected) ;;
*)
echo "Unbekannter Testmodus: $mode" >&2
exit 2
;;
esac
encode_base64() {
printf '%s' "$1" | base64 | tr -d '\r\n'
}
mode_base64="$(encode_base64 "$mode")"
selection_base64="$(encode_base64 "$selection")"
php_code="ob_start(); require_once IPS_GetKernelDir() . 'modules/Enelix-EMS/tests/Symcon/bootstrap.php'; "
php_code+="\$mode = base64_decode('$mode_base64'); "
php_code+="\$selection = array_values(array_filter(explode(',', base64_decode('$selection_base64')), "
php_code+="static fn (string \$value): bool => \$value !== '')); "
php_code+="\$result = enelixEmsRunSymconTests(\$mode, \$selection); "
php_code+="ob_end_clean(); "
php_code+="echo implode(':', ["
php_code+="base64_encode(json_encode(\$result, JSON_THROW_ON_ERROR)), "
php_code+="base64_encode(json_encode(\$result['report'], JSON_THROW_ON_ERROR)), "
php_code+="base64_encode(\$result['junit']), "
php_code+="base64_encode(\$result['console']), "
php_code+="\$result['report']['passed'] ? '1' : '0'"
php_code+="]);"
php_code_base64="$(encode_base64 "$php_code")"
rpc_code="eval(base64_decode('$php_code_base64'));"
payload="$(printf '{"jsonrpc":"2.0","id":1,"method":"IPS_RunScriptTextWaitEx","params":["%s",{}]}' "$rpc_code")"
mkdir -p "$output_dir"
response_file="$output_dir/rpc-response.json"
result_file="$output_dir/result.json"
curl --silent --show-error --fail \
--header 'Content-Type: application/json' \
--data "$payload" \
"$url" > "$response_file"
if grep -q '"error"' "$response_file"; then
sed -n '1,120p' "$response_file" >&2
exit 1
fi
wire="$(sed -n 's/^.*"result":"\([^"]*\)".*$/\1/p' "$response_file" | sed 's#\\/#/#g')"
if [[ -z "$wire" ]]; then
echo 'IP-Symcon lieferte keine auswertbaren Testdaten:' >&2
sed -n '1,120p' "$response_file" >&2
exit 1
fi
IFS=: read -r result_base64 report_base64 junit_base64 console_base64 passed extra <<< "$wire"
if [[ -n "${extra:-}" || -z "$result_base64" || -z "$report_base64" || -z "$junit_base64" ]]; then
echo 'IP-Symcon lieferte ein ungültiges Testergebnis.' >&2
exit 1
fi
printf '%s' "$result_base64" | base64 -d > "$result_file"
printf '%s' "$report_base64" | base64 -d > "$output_dir/report.json"
printf '%s' "$junit_base64" | base64 -d > "$output_dir/junit.xml"
printf '%s' "$console_base64" | base64 -d
[[ "$passed" == 1 ]]
+19
View File
@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
use Belevo\EnelixEMS\SymconTest\TestRunner;
require_once __DIR__ . '/TestContext.php';
require_once __DIR__ . '/TestRunner.php';
/**
* @param list<string>|string $selection
* @return array{report: array<string, mixed>, console: string, junit: string}
*/
function enelixEmsRunSymconTests(string $mode = 'all', $selection = []): array
{
$runner = new TestRunner('Enelix-EMS', __DIR__ . '/manifest.php');
return $runner->run($mode, $selection);
}
+58
View File
@@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
return [
'LadestationStandAlone' => [
'file' => __DIR__ . '/modules/LadestationStandAlone.php',
'paths' => [
'LadestationStandAlone/*',
'libs/LadestationAdapter.php',
'libs/LadestationRegler.php',
'libs/VerbraucherBasisTrait.php',
'libs/VerbraucherSchnittstelle.php',
'libs/Nachrichtenvertrag.php',
],
],
'Manager' => [
'file' => __DIR__ . '/modules/Manager.php',
'paths' => [
'Manager/*',
'libs/Lizenzpruefung.php',
'libs/ManagerRegler.php',
'libs/ManagerSchnittstelle.php',
'libs/Nachrichtenvertrag.php',
'libs/VerbraucherSchnittstelle.php',
],
],
'Pufferspeicher' => [
'file' => __DIR__ . '/modules/Pufferspeicher.php',
'paths' => [
'Pufferspeicher/*',
'libs/PufferspeicherRegler.php',
'libs/VerbraucherBasisTrait.php',
'libs/VerbraucherSchnittstelle.php',
'libs/Nachrichtenvertrag.php',
],
],
'VerbraucherEinStufig' => [
'file' => __DIR__ . '/modules/VerbraucherEinStufig.php',
'paths' => [
'VerbraucherEinStufig/*',
'libs/EinStufigRegler.php',
'libs/VerbraucherBasisTrait.php',
'libs/VerbraucherSchnittstelle.php',
'libs/Nachrichtenvertrag.php',
],
],
'Warmwassererwaermer' => [
'file' => __DIR__ . '/modules/Warmwassererwaermer.php',
'paths' => [
'Warmwassererwaermer/*',
'libs/WarmwasserRegler.php',
'libs/VerbraucherBasisTrait.php',
'libs/VerbraucherSchnittstelle.php',
'libs/Nachrichtenvertrag.php',
],
],
];
@@ -0,0 +1,177 @@
<?php
declare(strict_types=1);
use Belevo\EnelixEMS\SymconTest\TestContext;
return static function (TestContext $test): void {
$testPasswort = hash('sha256', __FILE__);
$goEAntwort = static function (int $status, float $leistung): string {
$nrg = array_fill(0, 12, 0);
$nrg[11] = $leistung;
return json_encode(['car' => $status, 'nrg' => $nrg], JSON_THROW_ON_ERROR);
};
$definitionen = [
'go-e alt' => [
'Geraetetyp' => 1,
'Geraeteadresse' => '192.0.2.10',
'Testantwort' => $goEAntwort(2, 1100),
'Phasenzahl' => 3,
'BefehlUrlTeil' => 'amp=16',
],
'go-e Gemini' => [
'Geraetetyp' => 2,
'Geraeteadresse' => '192.0.2.11',
'Testantwort' => $goEAntwort(2, 2300),
'Phasenzahl' => 1,
'BefehlUrlTeil' => 'amp=16',
],
'smart-me Pico' => [
'Geraetetyp' => 3,
'GeraeteID' => 'pico-id',
'Seriennummer' => 'serial-1',
'Benutzername' => 'api-user',
'Passwort' => $testPasswort,
'Testantwort' => json_encode([
'State' => 2,
'ActiveChargingPower' => 11.0,
'MaxAllowedChargingCurrent' => 16,
], JSON_THROW_ON_ERROR),
'Phasenzahl' => 3,
'BefehlUrlTeil' => 'current=16000',
],
];
$stationen = [];
foreach ($definitionen as $name => $definition) {
$properties = array_merge($definition, [
'MaximalerLadestrom' => 16,
'Ladefreigabe' => true,
'Solarladen' => true,
'EinstellungenInVisu' => true,
'DiagnosevariablenAnzeigen' => true,
'Testmodus' => true,
]);
unset($properties['Phasenzahl'], $properties['BefehlUrlTeil']);
$stationen[$name] = $test->createInstance(
'{0D94913C-0F31-4C29-A685-6EB4AE58E55D}',
'Ladestation ' . $name,
$properties
);
}
$managerId = $test->createInstance(
'{6F771B18-59D4-4C8A-B951-3B2FE9F6A2C4}',
'Hilfsmanager Ladestation',
[
'AutomatischeSuche' => false,
'VerbraucherZuordnung' => json_encode(array_map(
static fn (int $id): array => ['InstanzID' => $id, 'Aktiv' => true],
array_values($stationen)
), JSON_THROW_ON_ERROR),
]
);
$anforderungSenden = static function (
int $stationId,
int $leistung,
string $betriebsart = 'PV'
) use ($managerId): void {
IPS_RequestAction($stationId, 'ManagerdatenEmpfangen', json_encode([
'Kopf' => [
'Version' => '4.0',
'AbsenderID' => $managerId,
'EmpfaengerID' => $stationId,
'Zeitpunkt' => time(),
],
'Betriebsart' => $betriebsart,
'Sollleistung_W' => $leistung,
], JSON_THROW_ON_ERROR));
};
$test->runCase('Alle drei Geraetevarianten erkennen Fahrzeug und Phasen', static function (
TestContext $test
) use ($definitionen, $stationen): void {
foreach ($stationen as $name => $stationId) {
IPS_RequestAction($stationId, 'Aktiv', true);
$test->assertSame(true, GetValue($test->objectByIdent('FahrzeugVerbunden', $stationId)));
$test->assertSame(
$definitionen[$name]['Phasenzahl'],
GetValue($test->objectByIdent('Phasenzahl', $stationId))
);
$test->assertSame(false, GetValue($test->objectByIdent('FahrzeugGeladen', $stationId)));
IPS_RequestAction($stationId, 'Solarladen', false);
$befehl = (string) GetValue($test->objectByIdent('LetzterGeraetebefehl', $stationId));
$diagnose = json_encode([
'Aktiv' => GetValue($test->objectByIdent('Aktiv', $stationId)),
'Ladefreigabe' => GetValue($test->objectByIdent('Ladefreigabe', $stationId)),
'Solarladen' => GetValue($test->objectByIdent('Solarladen', $stationId)),
'FahrzeugGeladen' => GetValue($test->objectByIdent('FahrzeugGeladen', $stationId)),
'Phasenzahl' => GetValue($test->objectByIdent('Phasenzahl', $stationId)),
'Status' => IPS_GetInstance($stationId)['InstanceStatus'],
'Stoertext' => GetValue($test->objectByIdent('Stoertext', $stationId)),
], JSON_THROW_ON_ERROR);
$test->assertTrue(str_contains(
$befehl,
$definitionen[$name]['BefehlUrlTeil']
), $name . ': Der erwartete 16-A-Geraetebefehl fehlt. Erhalten: ' . $befehl
. '; Diagnose: ' . $diagnose);
}
});
$test->runCase('Solar- und Peakbetrieb liefern die Enelix-1-Angebote', static function (
TestContext $test
) use ($stationen, $anforderungSenden): void {
$stationId = $stationen['go-e alt'];
IPS_RequestAction($stationId, 'Solarladen', true);
$anforderungSenden($stationId, 4150);
$befehl = (string) GetValue($test->objectByIdent('LetzterGeraetebefehl', $stationId));
$diagnose = json_encode([
'Sollleistung' => GetValue($test->objectByIdent('Sollleistung', $stationId)),
'SollwertGueltig' => GetValue($test->objectByIdent('SollwertGueltig', $stationId)),
'Status' => IPS_GetInstance($stationId)['InstanceStatus'],
'Stoertext' => GetValue($test->objectByIdent('Stoertext', $stationId)),
], JSON_THROW_ON_ERROR);
$test->assertTrue(str_contains(
$befehl,
'amp=6'
), 'Solarladen: Der 6-A-Befehl fehlt. Erhalten: ' . $befehl . '; Diagnose: ' . $diagnose);
IPS_RequestAction($stationId, 'Solarladen', false);
$angebot = (string) GetValue($test->objectByIdent('LeistungsangebotDiagnose', $stationId));
$test->assertSame('[11000]', $angebot, 'PV ohne Solarladen muss nur das Maximum anbieten.');
$abgewiesen = false;
try {
$anforderungSenden($stationId, 4150);
} catch (Throwable $fehler) {
$abgewiesen = true;
}
if (!$abgewiesen) {
$abgewiesen = GetValue($test->objectByIdent('SollwertGueltig', $stationId)) !== true
|| GetValue($test->objectByIdent('Sollleistung', $stationId)) !== 4150;
}
$test->assertSame(true, $abgewiesen, 'Normalbetrieb muss eine Zwischenstufe ablehnen.');
$anforderungSenden($stationId, 4150, 'Peak');
$test->assertSame(
'[0,4150,4850,5550,6250,6950,7600,8300,9000,9700,10300,11000]',
(string) GetValue($test->objectByIdent('LeistungsangebotDiagnose', $stationId))
);
$test->assertTrue(str_contains(
(string) GetValue($test->objectByIdent('LetzterGeraetebefehl', $stationId)),
'amp=6'
), 'Peak ohne Solarladen muss die Ladestufen annehmen.');
IPS_RequestAction($stationId, 'Solarladen', true);
$anforderungSenden($stationId, 0, 'Peak');
$test->assertSame(
'[0]',
(string) GetValue($test->objectByIdent('LeistungsangebotDiagnose', $stationId))
);
$test->assertTrue(str_contains(
(string) GetValue($test->objectByIdent('LetzterGeraetebefehl', $stationId)),
'alw=0'
), 'Peak mit Solarladen muss die Ladestation stoppen.');
});
};
+279
View File
@@ -0,0 +1,279 @@
<?php
declare(strict_types=1);
use Belevo\EnelixEMS\SymconTest\TestContext;
return static function (TestContext $test): void {
$netzleistungId = $test->createVariable(2, 'Netzleistung', 0.0);
$einKontaktId = $test->createActionVariable('Kontakt Verbraucher 1-Stufig');
$einStufigId = $test->createInstance(
'{15879A4E-D0C2-4495-83DE-46E1E462591E}',
'Verbraucher 1-Stufig',
[
'Nennleistung' => 1000,
'SchaltkontaktVariableID' => $einKontaktId,
'Mindesteinschaltdauer' => 0,
'Mindestausschaltdauer' => 0,
'PrioritaetPV' => 0,
'PrioritaetPeak' => 0,
'DiagnosevariablenAnzeigen' => true,
]
);
$temperaturId = $test->createVariable(2, 'Boilertemperatur', 52.0);
$warmKontakte = [
$test->createActionVariable('Warmwasser Stufe 1'),
$test->createActionVariable('Warmwasser Stufe 2'),
];
$warmStufen = [
['Stufe' => 1, 'Leistung' => 1000, 'Schaltkontakt_Stufe' => $warmKontakte[0]],
['Stufe' => 2, 'Leistung' => 2000, 'Schaltkontakt_Stufe' => $warmKontakte[1]],
];
$warmwasserId = $test->createInstance(
'{B7C54AF4-AD7D-4FE4-B75D-203693906251}',
'Wassererwärmer',
[
'Boilerfuehler_PT1' => $temperaturId,
'LeistungsStufen' => json_encode($warmStufen, JSON_THROW_ON_ERROR),
'LastwechselSperrzeit' => 1,
'LegionellenfunktionAktiv' => false,
'PrioritaetPV' => 1,
'PrioritaetPeak' => 1,
'DiagnosevariablenAnzeigen' => true,
]
);
$pufferTemperaturId = $test->createVariable(2, 'Puffertemperatur', 52.0);
$aussenTemperaturId = $test->createVariable(2, 'Aussentemperatur', 5.0);
$pufferKontakte = [
$test->createActionVariable('Pufferspeicher Stufe 1'),
$test->createActionVariable('Pufferspeicher Stufe 2'),
];
$pufferspeicherId = $test->createInstance(
'{C92D5EEF-9632-47A5-9659-4B02BF40FBE9}',
'Pufferspeicher',
[
'PufferfuehlerVariableID' => $pufferTemperaturId,
'AussentemperaturVariableID' => $aussenTemperaturId,
'LeistungsStufen' => json_encode([
['Stufe' => 1, 'Leistung' => 1000, 'Schaltkontakt_Stufe' => $pufferKontakte[0]],
['Stufe' => 2, 'Leistung' => 2000, 'Schaltkontakt_Stufe' => $pufferKontakte[1]],
], JSON_THROW_ON_ERROR),
'LastwechselSperrzeit' => 0,
'PrioritaetPV' => 2,
'PrioritaetPeak' => 2,
'DiagnosevariablenAnzeigen' => true,
]
);
$zuordnung = [
['InstanzID' => $einStufigId, 'Aktiv' => true],
['InstanzID' => $warmwasserId, 'Aktiv' => true],
['InstanzID' => $pufferspeicherId, 'Aktiv' => true],
];
$lizenzcode = trim((string) getenv('ENELIX_TEST_LICENSE_CODE'));
$managerId = $test->createInstance(
'{6F771B18-59D4-4C8A-B951-3B2FE9F6A2C4}',
'Manager',
[
'NetzleistungVariableID' => $netzleistungId,
'Lizenzcode' => $lizenzcode,
'AutomatischeSuche' => false,
'VerbraucherZuordnung' => json_encode($zuordnung, JSON_THROW_ON_ERROR),
'KeepAlive' => 30,
'VerbraucherTimeout' => 10,
'Lastspitzenmodus' => 1,
'Lastspitzengrenze' => 0.0,
'SollwertSolarladen' => 0.0,
'Umschaltdifferenz' => 0.0,
'DiagnosevariablenAnzeigen' => true,
]
);
$melden = static function () use ($einStufigId, $warmwasserId, $pufferspeicherId): void {
foreach ([$einStufigId, $warmwasserId, $pufferspeicherId] as $instanzId) {
IPS_RequestAction($instanzId, 'Aktiv', true);
IPS_RequestAction($instanzId, 'Melden', true);
}
};
$regeln = static function (float $netzleistung) use (
$netzleistungId,
$managerId,
$einStufigId,
$warmwasserId,
$pufferspeicherId
): void {
IPS_RequestAction($einStufigId, 'Melden', true);
IPS_RequestAction($warmwasserId, 'Melden', true);
IPS_RequestAction($pufferspeicherId, 'Melden', true);
SetValue($netzleistungId, $netzleistung);
IPS_RequestAction($managerId, 'Regeln', true);
};
$kontaktzustand = static fn (): array => [
(bool) GetValue($einKontaktId),
(bool) GetValue($warmKontakte[0]),
(bool) GetValue($warmKontakte[1]),
(bool) GetValue($pufferKontakte[0]),
(bool) GetValue($pufferKontakte[1]),
];
$warteAufKontakte = static function (array $erwartet, string $schritt) use (
$test,
$kontaktzustand,
$managerId
): void {
$deadline = microtime(true) + 5.0;
do {
if ($kontaktzustand() === $erwartet) {
return;
}
IPS_Sleep(50);
} while (microtime(true) < $deadline);
$test->fail(sprintf(
'%s: Kontaktbild %s statt %s; Betriebsart %s; Verteilbudget %.0f W.',
$schritt,
json_encode($kontaktzustand(), JSON_THROW_ON_ERROR),
json_encode($erwartet, JSON_THROW_ON_ERROR),
(string) GetValue($test->objectByIdent('Betriebsart', $managerId)),
(float) GetValue($test->objectByIdent('Verteilbudget', $managerId))
));
};
$verbraucherstatus = static function () use ($test, $managerId): array {
$json = (string) GetValue($test->objectByIdent('Verbraucherstatus', $managerId));
$daten = json_decode($json, true, 512, JSON_THROW_ON_ERROR);
return is_array($daten) ? $daten : [];
};
$zustandswert = static function (array $daten, int $instanzId, string $kennung) {
foreach ($daten['Verbraucher'] ?? [] as $verbraucher) {
if (($verbraucher['InstanzID'] ?? 0) !== $instanzId) {
continue;
}
foreach ($verbraucher['Daten']['Zustand'] ?? [] as $zustand) {
if (($zustand['Kennung'] ?? '') === $kennung) {
return $zustand['Wert'] ?? null;
}
}
}
return null;
};
if ($lizenzcode === '') {
$test->runCase('Manager bleibt ohne Lizenz sicher gesperrt', static function (
TestContext $test
) use ($managerId): void {
IPS_RequestAction($managerId, 'Aktiv', true);
IPS_RequestAction($managerId, 'Regeln', true);
$test->assertInstanceStatus($managerId, 203);
$test->assertSame(
'Inaktiv',
GetValue($test->objectByIdent('Betriebsart', $managerId))
);
});
return;
}
$test->runCase('Manager und alle vorhandenen Verbrauchertypen sind verknüpft', static function (
TestContext $test
) use ($managerId, $einStufigId, $warmwasserId, $pufferspeicherId, $melden): void {
IPS_RequestAction($managerId, 'Aktiv', true);
$melden();
IPS_RequestAction($managerId, 'Regeln', true);
foreach ([$managerId, $einStufigId, $warmwasserId, $pufferspeicherId] as $instanzId) {
$test->assertInstanceStatus($instanzId);
}
$test->assertSame(
3,
GetValue($test->objectByIdent('VerbraucherAnzahl', $managerId))
);
$test->assertSame(
3,
GetValue($test->objectByIdent('VerbraucherVerfuegbar', $managerId))
);
});
$test->runCase('Netzleistung wird in beide Richtungen in PV und Peak geregelt', static function (
TestContext $test
) use ($managerId, $regeln, $warteAufKontakte): void {
$regeln(-3000.0);
$warteAufKontakte([true, false, true, false, false], 'Netz -3000 W');
$test->assertSame('PV', GetValue($test->objectByIdent('Betriebsart', $managerId)));
$regeln(-1000.0);
$warteAufKontakte([true, false, true, false, false], 'Netz -1000 W');
$regeln(0.0);
$warteAufKontakte([true, false, true, false, false], 'Netz 0 W');
IPS_Sleep(1100);
$regeln(1000.0);
$warteAufKontakte([false, false, false, false, false], 'Netz +1000 W');
$test->assertSame('Peak', GetValue($test->objectByIdent('Betriebsart', $managerId)));
IPS_Sleep(1100);
$regeln(3000.0);
$warteAufKontakte([false, false, false, false, false], 'Netz +3000 W');
});
$test->runCase('Keep-alive ist aktiv und erneuert unveränderte Sollwerte', static function (
TestContext $test
) use ($managerId, $einStufigId): void {
$sollwertId = $test->objectByIdent('Sollleistung', $einStufigId);
$vorher = (int) IPS_GetVariable($sollwertId)['VariableUpdated'];
IPS_Sleep(1100);
IPS_RequestAction($managerId, 'Regeln', true);
$test->waitUntil(
static fn (): bool => (int) IPS_GetVariable($sollwertId)['VariableUpdated'] > $vorher
);
});
$test->runCase('Alle definierten Nachrichtenauslöser aktualisieren den Manager', static function (
TestContext $test
) use (
$managerId,
$einStufigId,
$warmwasserId,
$temperaturId,
$melden,
$verbraucherstatus,
$zustandswert
): void {
$melden();
IPS_RequestAction($managerId, 'Regeln', false);
$daten = $verbraucherstatus();
$test->assertSame(3, count($daten['Verbraucher'] ?? []));
SetValue($temperaturId, 53.0);
$test->waitUntil(static function () use (
$managerId,
$warmwasserId,
$verbraucherstatus,
$zustandswert
): bool {
IPS_RequestAction($managerId, 'Regeln', false);
$wert = $zustandswert(
$verbraucherstatus(),
$warmwasserId,
'Wassertemperatur_C'
);
return is_numeric($wert) && abs((float) $wert - 53.0) < 0.01;
}, 4000, 100);
IPS_RequestAction($einStufigId, 'Melden', true);
IPS_RequestAction($warmwasserId, 'Melden', true);
IPS_RequestAction($managerId, 'Regeln', true);
$daten = $verbraucherstatus();
$test->assertTrue(
is_bool($zustandswert($daten, $einStufigId, 'Schaltzustand'))
);
$test->assertTrue(
is_int($zustandswert($daten, $warmwasserId, 'AktiveStufe'))
);
$test->assertSame(false, GetValue(
$test->objectByIdent('Sammelstoerung', $managerId)
));
});
};
+155
View File
@@ -0,0 +1,155 @@
<?php
declare(strict_types=1);
use Belevo\EnelixEMS\SymconTest\TestContext;
return static function (TestContext $test): void {
$pufferId = $test->createVariable(2, 'Puffertemperatur', 40.0, null, '~Temperature');
$aussenId = $test->createVariable(2, 'Aussentemperatur', 5.0, null, '~Temperature');
$wpSollId = $test->createVariable(2, 'WP Solltemperatur', 50.0, null, '~Temperature');
$kontakte = [
$test->createActionVariable('Puffer Stufe 1'),
$test->createActionVariable('Puffer Stufe 2'),
];
$stufen = [
['Stufe' => 1, 'Leistung' => 1000, 'Schaltkontakt_Stufe' => $kontakte[0]],
['Stufe' => 2, 'Leistung' => 2000, 'Schaltkontakt_Stufe' => $kontakte[1]],
];
$verbraucherId = $test->createInstance(
'{C92D5EEF-9632-47A5-9659-4B02BF40FBE9}',
'Pufferspeicher',
[
'PufferfuehlerVariableID' => $pufferId,
'AussentemperaturVariableID' => $aussenId,
'WaermepumpenSolltemperaturVariableID' => $wpSollId,
'LeistungsStufen' => json_encode($stufen, JSON_THROW_ON_ERROR),
'FusspunktVorlauftemperatur' => 35.0,
'HeizkurvenSteigung' => 1.0,
'HeizkurveMinimaltemperatur' => 20.0,
'HeizkurveMaximaltemperatur' => 80.0,
'Hysterese' => 5.0,
'LastwechselSperrzeit' => 0,
'TemperaturMaxAlter' => 120,
'DiagnosevariablenAnzeigen' => true,
]
);
$managerId = $test->createInstance(
'{6F771B18-59D4-4C8A-B951-3B2FE9F6A2C4}',
'Hilfsmanager Pufferspeicher',
[
'AutomatischeSuche' => false,
'VerbraucherZuordnung' => json_encode([
['InstanzID' => $verbraucherId, 'Aktiv' => true],
], JSON_THROW_ON_ERROR),
]
);
$managerdatenSenden = static function (
string $betriebsart,
?int $leistung
) use ($managerId, $verbraucherId): void {
IPS_RequestAction($verbraucherId, 'ManagerdatenEmpfangen', json_encode([
'Kopf' => [
'Version' => '4.0',
'AbsenderID' => $managerId,
'EmpfaengerID' => $verbraucherId,
'Zeitpunkt' => time(),
],
'Betriebsart' => $betriebsart,
'Sollleistung_W' => $leistung,
], JSON_THROW_ON_ERROR));
};
$kontaktzustand = static fn (): array => array_map(
static fn (int $id): bool => (bool) GetValue($id),
$kontakte
);
$test->runCase('Instanz und Variablen werden angelegt', static function (
TestContext $test
) use ($verbraucherId): void {
$test->assertInstanceStatus($verbraucherId);
foreach ([
'Aktiv',
'Puffertemperatur',
'Aussentemperatur',
'Solltemperatur',
'Heizbedarf',
'Istleistung',
'Sollleistung',
'AktiveStufe',
'TemperaturenGueltig',
] as $ident) {
$test->objectByIdent($ident, $verbraucherId);
}
});
$test->runCase('Unter der Einschaltschwelle kann PV eine Stufe schalten', static function (
TestContext $test
) use ($verbraucherId, $managerdatenSenden, $kontaktzustand): void {
IPS_RequestAction($verbraucherId, 'Aktiv', true);
$managerdatenSenden('PV', null);
$test->assertSame(true, GetValue($test->objectByIdent('Heizbedarf', $verbraucherId)));
$managerdatenSenden('PV', 2000);
$test->waitUntil(static fn (): bool => $kontaktzustand() === [false, true]);
$test->assertSame([false, true], $kontaktzustand());
});
$test->runCase('In der Hysterese und oberhalb des Sollwerts wird ausgeschaltet', static function (
TestContext $test
) use ($pufferId, $verbraucherId, $kontaktzustand): void {
SetValue($pufferId, 47.0);
IPS_RequestAction($verbraucherId, 'Melden', true);
$test->waitUntil(static fn (): bool => $kontaktzustand() === [false, false]);
$test->assertSame(false, GetValue($test->objectByIdent('Heizbedarf', $verbraucherId)));
SetValue($pufferId, 55.0);
IPS_RequestAction($verbraucherId, 'Melden', true);
$test->assertSame([false, false], $kontaktzustand());
});
$test->runCase('Peakbetrieb sperrt den Pufferspeicher unabhaengig von der Temperatur', static function (
TestContext $test
) use ($pufferId, $verbraucherId, $managerdatenSenden, $kontaktzustand): void {
SetValue($pufferId, 20.0);
$managerdatenSenden('Peak', null);
$test->assertSame(false, GetValue($test->objectByIdent('Heizbedarf', $verbraucherId)));
$test->waitUntil(static fn (): bool => $kontaktzustand() === [false, false]);
$abgewiesen = false;
try {
$managerdatenSenden('Peak', 1000);
} catch (Throwable $fehler) {
$abgewiesen = true;
}
if (!$abgewiesen) {
$abgewiesen = GetValue($test->objectByIdent('SollwertGueltig', $verbraucherId)) !== true
|| GetValue($test->objectByIdent('Sollleistung', $verbraucherId)) !== 1000;
}
$test->assertSame(true, $abgewiesen, 'Peak darf keine Heizstufe anbieten.');
$test->assertSame([false, false], $kontaktzustand());
});
$test->runCase('Lokales Ausschalten schaltet jede Stufe aus', static function (
TestContext $test
) use ($verbraucherId, $kontaktzustand): void {
IPS_RequestAction($verbraucherId, 'Aktiv', false);
$test->waitUntil(static fn (): bool => $kontaktzustand() === [false, false]);
$test->assertSame([false, false], $kontaktzustand());
});
$test->runCase('WP-Button kalibriert den Heizkurven-Fusspunkt', static function (
TestContext $test
) use ($verbraucherId): void {
IPS_SetProperty($verbraucherId, 'FusspunktVorlauftemperatur', 30.0);
IPS_ApplyChanges($verbraucherId);
IPS_RequestAction($verbraucherId, 'HeizkurveVonWaermepumpeUebernehmen', 0);
$test->assertEquals(
35.0,
(float) IPS_GetProperty($verbraucherId, 'FusspunktVorlauftemperatur'),
0.001
);
});
};
@@ -0,0 +1,228 @@
<?php
declare(strict_types=1);
use Belevo\EnelixEMS\SymconTest\TestContext;
return static function (TestContext $test): void {
$kontaktId = $test->createActionVariable('Schaltkontakt');
$rueckmeldungId = $test->createVariable(0, 'Rückmeldung', false);
$verbraucherId = $test->createInstance(
'{15879A4E-D0C2-4495-83DE-46E1E462591E}',
'Verbraucher 1-Stufig',
[
'Nennleistung' => 1200,
'SchaltkontaktVariableID' => $kontaktId,
'RueckmeldungVariableID' => $rueckmeldungId,
'Mindestlaufzeit' => 0,
'Mindesteinschaltdauer' => 2,
'Mindestausschaltdauer' => 2,
'VorgabeTimeout' => 30,
'DiagnosevariablenAnzeigen' => true,
]
);
$managerId = $test->createInstance(
'{6F771B18-59D4-4C8A-B951-3B2FE9F6A2C4}',
'Hilfsmanager',
[
'AutomatischeSuche' => false,
'VerbraucherZuordnung' => json_encode([
['InstanzID' => $verbraucherId, 'Aktiv' => true],
], JSON_THROW_ON_ERROR),
]
);
$anforderungSenden = static function (
?int $leistung,
string $betriebsart = 'PV'
) use (
$managerId,
$verbraucherId
): void {
$paket = [
'Kopf' => [
'Version' => '4.0',
'AbsenderID' => $managerId,
'EmpfaengerID' => $verbraucherId,
'Zeitpunkt' => time(),
],
'Betriebsart' => $betriebsart,
'Sollleistung_W' => $leistung,
];
IPS_RequestAction(
$verbraucherId,
'ManagerdatenEmpfangen',
json_encode($paket, JSON_THROW_ON_ERROR)
);
};
$test->runCase('Instanz und Diagnosevariablen werden angelegt', static function (
TestContext $test
) use ($verbraucherId): void {
$test->assertInstanceStatus($verbraucherId);
foreach ([
'Aktiv', 'Schaltzustand', 'Tageslaufzeit', 'Istleistung', 'Sollleistung',
'AenderungMoeglich', 'Rueckmeldefehler', 'SchaltbefehlAusstehend',
'RestMindestzeit',
] as $ident) {
$test->objectByIdent($ident, $verbraucherId);
}
});
$test->runCase('PV und Peak liefern unterschiedliche Leistungsangebote', static function (
TestContext $test
) use ($verbraucherId, $anforderungSenden): void {
IPS_RequestAction($verbraucherId, 'Aktiv', true);
IPS_Sleep(2100);
$anforderungSenden(null, 'Peak');
$abgewiesen = false;
try {
$anforderungSenden(1200, 'Peak');
} catch (Throwable $fehler) {
$abgewiesen = true;
}
if (!$abgewiesen) {
$abgewiesen = GetValue($test->objectByIdent('SollwertGueltig', $verbraucherId)) !== true
|| GetValue($test->objectByIdent('Sollleistung', $verbraucherId)) !== 1200;
}
$test->assertSame(true, $abgewiesen, 'Peak darf die Nennleistung nicht anbieten.');
$anforderungSenden(null, 'PV');
});
$test->runCase('Nur angebotene Leistungsanforderungen werden angenommen', static function (
TestContext $test
) use (
$verbraucherId,
$kontaktId,
$rueckmeldungId,
$anforderungSenden
): void {
IPS_RequestAction($verbraucherId, 'Aktiv', true);
IPS_Sleep(2100);
$anforderungSenden(600);
$test->assertSame(false, GetValue($kontaktId));
$test->assertSame(
0,
(int) GetValue($test->objectByIdent('Sollleistung', $verbraucherId))
);
$anforderungSenden(1200);
$test->waitUntil(static fn (): bool => GetValue($kontaktId) === true);
$test->assertSame(false, GetValue(
$test->objectByIdent('Schaltzustand', $verbraucherId)
));
$test->assertSame(true, GetValue(
$test->objectByIdent('SchaltbefehlAusstehend', $verbraucherId)
));
SetValue($rueckmeldungId, true);
$test->waitUntil(static fn (): bool => GetValue(
$test->objectByIdent('Schaltzustand', $verbraucherId)
) === true);
$test->assertSame(
1200,
(int) round((float) GetValue($test->objectByIdent('Istleistung', $verbraucherId)))
);
});
$test->runCase('Mindesteinschaltdauer verhindert zu frühes Ausschalten', static function (
TestContext $test
) use (
$verbraucherId,
$kontaktId,
$rueckmeldungId,
$anforderungSenden
): void {
$anforderungSenden(0);
$test->assertSame(true, GetValue($kontaktId));
$test->assertTrue(
(int) GetValue($test->objectByIdent('RestMindestzeit', $verbraucherId)) > 0
);
IPS_Sleep(2100);
$anforderungSenden(0);
$test->waitUntil(static fn (): bool => GetValue($kontaktId) === false);
$test->assertSame(true, GetValue(
$test->objectByIdent('SchaltbefehlAusstehend', $verbraucherId)
));
SetValue($rueckmeldungId, false);
$test->waitUntil(static fn (): bool => GetValue(
$test->objectByIdent('Schaltzustand', $verbraucherId)
) === false);
});
$test->runCase('Mindestausschaltdauer verhindert zu frühes Einschalten', static function (
TestContext $test
) use (
$kontaktId,
$rueckmeldungId,
$anforderungSenden
): void {
$anforderungSenden(1200);
$test->assertSame(false, GetValue($kontaktId));
IPS_Sleep(2100);
$anforderungSenden(1200);
$test->waitUntil(static fn (): bool => GetValue($kontaktId) === true);
SetValue($rueckmeldungId, true);
});
$test->runCase('Peak kann die Tagesmindestlaufzeit ohne angebotene Sperre erzwingen', static function (
TestContext $test
) use (
$verbraucherId,
$kontaktId,
$rueckmeldungId,
$anforderungSenden
): void {
IPS_Sleep(2100);
$anforderungSenden(0);
$test->waitUntil(static fn (): bool => GetValue($kontaktId) === false);
SetValue($rueckmeldungId, false);
$test->waitUntil(static fn (): bool => GetValue(
$test->objectByIdent('Schaltzustand', $verbraucherId)
) === false);
IPS_Sleep(2100);
$anforderungSenden(null, 'Peak');
IPS_SetProperty($verbraucherId, 'PeakSperreBeiMindestlaufzeitAnbieten', false);
IPS_SetProperty($verbraucherId, 'Mindestlaufzeit', 86400);
IPS_ApplyChanges($verbraucherId);
$test->waitUntil(static fn (): bool => GetValue($kontaktId) === true);
$test->assertSame(1200, (int) GetValue(
$test->objectByIdent('Sollleistung', $verbraucherId)
));
SetValue($rueckmeldungId, true);
$test->waitUntil(static fn (): bool => GetValue(
$test->objectByIdent('Schaltzustand', $verbraucherId)
) === true);
IPS_SetProperty($verbraucherId, 'Mindestlaufzeit', 0);
IPS_SetProperty($verbraucherId, 'PeakSperreBeiMindestlaufzeitAnbieten', true);
IPS_ApplyChanges($verbraucherId);
$anforderungSenden(null, 'PV');
});
$test->runCase('Physische Rückmeldung bestätigt und überwacht den Zustand', static function (
TestContext $test
) use ($verbraucherId, $kontaktId, $rueckmeldungId): void {
$test->assertSame(true, GetValue($kontaktId));
SetValue($rueckmeldungId, false);
IPS_RequestAction($verbraucherId, 'Melden', true);
$test->waitUntil(static fn (): bool => GetValue(
$test->objectByIdent('Rueckmeldefehler', $verbraucherId)
) === true);
$test->assertSame(true, GetValue(
$test->objectByIdent('Stoerung', $verbraucherId)
));
SetValue($rueckmeldungId, true);
IPS_RequestAction($verbraucherId, 'Melden', true);
$test->waitUntil(static fn (): bool => GetValue(
$test->objectByIdent('Rueckmeldefehler', $verbraucherId)
) === false);
$test->assertInstanceStatus($verbraucherId);
});
};
@@ -0,0 +1,237 @@
<?php
declare(strict_types=1);
use Belevo\EnelixEMS\SymconTest\TestContext;
return static function (TestContext $test): void {
$temperaturId = $test->createVariable(2, 'Boilertemperatur', 52.0, null, '~Temperature');
$netzleistungId = $test->createVariable(2, 'Netzleistung', 0.0);
$kontakte = [
$test->createActionVariable('Heizstufe 1'),
$test->createActionVariable('Heizstufe 2'),
$test->createActionVariable('Heizstufe 3'),
];
$stufen = [];
foreach ($kontakte as $index => $kontaktId) {
$stufen[] = [
'Stufe' => $index + 1,
'Leistung' => ($index + 1) * 1000,
'Schaltkontakt_Stufe' => $kontaktId,
];
}
$verbraucherId = $test->createInstance(
'{B7C54AF4-AD7D-4FE4-B75D-203693906251}',
'Wassererwärmer',
[
'Boilerfuehler_PT1' => $temperaturId,
'Boilervolumen' => 300,
'LeistungsStufen' => json_encode($stufen, JSON_THROW_ON_ERROR),
'LastwechselSperrzeit' => 1,
'Hysterese' => 5.0,
'TemperaturMaxAlter' => 120,
'LegionellenfunktionAktiv' => true,
'LegionellenMinimalintervallTage' => 4,
'LegionellenMaximalintervallTage' => 7,
'EinstellungenInVisu' => true,
'DiagnosevariablenAnzeigen' => true,
]
);
$lizenzcode = trim((string) getenv('ENELIX_TEST_LICENSE_CODE'));
$managerId = $test->createInstance(
'{6F771B18-59D4-4C8A-B951-3B2FE9F6A2C4}',
'Hilfsmanager',
[
'NetzleistungVariableID' => $netzleistungId,
'Lizenzcode' => $lizenzcode,
'AutomatischeSuche' => false,
'VerbraucherZuordnung' => json_encode([
['InstanzID' => $verbraucherId, 'Aktiv' => true],
], JSON_THROW_ON_ERROR),
'Lastspitzenmodus' => 1,
'Lastspitzengrenze' => 0.0,
'SollwertSolarladen' => 0.0,
'Umschaltdifferenz' => 0.0,
'KeepAlive' => 5,
'DiagnosevariablenAnzeigen' => true,
]
);
$kontaktzustand = static fn (): array => array_map(
static fn (int $id): bool => (bool) GetValue($id),
$kontakte
);
$warteAufKontakte = static function (array $erwartet) use (
$test,
$kontaktzustand
): void {
$test->waitUntil(
static fn (): bool => $kontaktzustand() === $erwartet,
4000,
50
);
$test->assertSame($erwartet, $kontaktzustand());
};
$temperaturSetzen = static function (float $wert) use (
$temperaturId,
$verbraucherId
): void {
SetValue($temperaturId, $wert);
IPS_RequestAction($verbraucherId, 'Melden', true);
};
$managerRegeln = static function (float $netzleistung) use (
$netzleistungId,
$verbraucherId,
$managerId
): void {
SetValue($netzleistungId, $netzleistung);
IPS_RequestAction($verbraucherId, 'Melden', true);
IPS_RequestAction($managerId, 'Regeln', true);
};
if ($lizenzcode === '') {
$test->runCase('Manager bleibt ohne Lizenz sicher gesperrt', static function (
TestContext $test
) use ($verbraucherId, $managerId): void {
$test->assertInstanceStatus($verbraucherId);
$test->assertInstanceStatus($managerId, 203);
});
return;
}
$test->runCase('Instanzen und Diagnosevariablen werden angelegt', static function (
TestContext $test
) use ($verbraucherId, $managerId): void {
$test->assertInstanceStatus($verbraucherId);
$test->assertInstanceStatus($managerId);
foreach ([
'Aktiv', 'Boilertemperatur', 'Mindesttemperatur', 'Maximaltemperatur',
'Legionellentemperatur', 'Istleistung', 'Sollleistung', 'AktiveStufe',
'TemperaturGueltig', 'NachladenAktiv', 'LegionellenbetriebAktiv',
] as $ident) {
$test->objectByIdent($ident, $verbraucherId);
}
});
$test->runCase('Temperaturmessung und Verbraucher werden aktiviert', static function (
TestContext $test
) use ($verbraucherId, $managerId): void {
IPS_RequestAction($verbraucherId, 'Aktiv', true);
IPS_RequestAction($managerId, 'Aktiv', true);
IPS_RequestAction($verbraucherId, 'Melden', true);
$test->assertEquals(
52.0,
(float) GetValue($test->objectByIdent('Boilertemperatur', $verbraucherId)),
0.01
);
$test->assertSame(
true,
GetValue($test->objectByIdent('TemperaturGueltig', $verbraucherId))
);
});
$test->runCase('Unter Minimum erzwingt PV das Maximum und Peak bietet alle Stufen', static function (
TestContext $test
) use (
$temperaturSetzen,
$managerRegeln,
$warteAufKontakte,
$managerId
): void {
$temperaturSetzen(44.0);
$managerRegeln(-3000.0);
$warteAufKontakte([false, false, true]);
$test->assertSame('PV', GetValue($test->objectByIdent('Betriebsart', $managerId)));
IPS_Sleep(1100);
$managerRegeln(1000.0);
$warteAufKontakte([false, true, false]);
$test->assertSame('Peak', GetValue($test->objectByIdent('Betriebsart', $managerId)));
IPS_Sleep(1100);
$temperaturSetzen(47.0);
$managerRegeln(1000.0);
$warteAufKontakte([true, false, false]);
IPS_Sleep(1100);
$temperaturSetzen(50.0);
$managerRegeln(1000.0);
$warteAufKontakte([false, false, false]);
});
$test->runCase('Zwischen Minimum und Maximum wählt PV Stufen und Peak null', static function (
TestContext $test
) use ($temperaturSetzen, $managerRegeln, $warteAufKontakte): void {
IPS_Sleep(1100);
$temperaturSetzen(52.0);
$managerRegeln(-3000.0);
$warteAufKontakte([false, false, true]);
IPS_Sleep(1100);
$managerRegeln(3000.0);
$warteAufKontakte([false, false, false]);
});
$test->runCase('Maximum sperrt bis unter die obere Hysterese', static function (
TestContext $test
) use ($temperaturSetzen, $managerRegeln, $warteAufKontakte): void {
$temperaturSetzen(60.0);
$managerRegeln(-3000.0);
$warteAufKontakte([false, false, false]);
$temperaturSetzen(58.0);
$managerRegeln(-3000.0);
$warteAufKontakte([false, false, false]);
$temperaturSetzen(54.9);
$managerRegeln(-3000.0);
$warteAufKontakte([false, false, true]);
});
$test->runCase('Erhöhtes Maximalziel bleibt im freien Peakbetrieb gesperrt', static function (
TestContext $test
) use (
$verbraucherId,
$temperaturSetzen,
$managerRegeln,
$warteAufKontakte
): void {
IPS_RequestAction($verbraucherId, 'Maximaltemperatur', 65.0);
$temperaturSetzen(55.0);
$managerRegeln(-3000.0);
$warteAufKontakte([false, false, true]);
IPS_Sleep(1100);
$managerRegeln(2000.0);
$warteAufKontakte([false, false, false]);
});
$test->runCase('Zeitplan bietet in Peak alle Stufen und erzwingt in PV das Maximum', static function (
TestContext $test
) use (
$verbraucherId,
$temperaturSetzen,
$managerRegeln,
$warteAufKontakte
): void {
IPS_Sleep(1100);
IPS_RequestAction($verbraucherId, 'Maximaltemperatur', 60.0);
$temperaturSetzen(52.0);
$managerRegeln(3000.0);
$warteAufKontakte([false, false, false]);
IPS_SetProperty($verbraucherId, 'Zeitplan', json_encode([
['Uhrzeit' => date('H:i', time() + 60), 'Solltemperatur' => 60],
], JSON_THROW_ON_ERROR));
IPS_ApplyChanges($verbraucherId);
$test->assertSame(true, GetValue(
$test->objectByIdent('AenderungMoeglich', $verbraucherId)
));
$warteAufKontakte([false, false, false]);
$managerRegeln(-3000.0);
$warteAufKontakte([false, false, true]);
});
};
+99
View File
@@ -0,0 +1,99 @@
<?php
declare(strict_types=1);
namespace Belevo\EnelixEMS\Tests;
use PHPUnit\Framework\TestCase;
final class SymconTestContractTest extends TestCase
{
public function testEveryModuleHasRegisteredSymconTest(): void
{
$root = dirname(__DIR__);
$moduleDirectories = array_map(
static fn (string $path): string => basename(dirname($path)),
glob($root . '/*/module.json') ?: []
);
sort($moduleDirectories);
$manifest = require __DIR__ . '/Symcon/manifest.php';
$registeredModules = array_keys($manifest);
sort($registeredModules);
self::assertSame(
$moduleDirectories,
$registeredModules,
'Jedes Modul benötigt einen Eintrag in tests/Symcon/manifest.php.'
);
}
public function testRegisteredScriptsReturnCallableTests(): void
{
$manifest = require __DIR__ . '/Symcon/manifest.php';
foreach ($manifest as $module => $definition) {
self::assertArrayHasKey('file', $definition, $module);
self::assertArrayHasKey('paths', $definition, $module);
self::assertFileExists($definition['file'], $module);
self::assertIsArray($definition['paths'], $module);
self::assertNotEmpty($definition['paths'], $module);
self::assertIsCallable(require $definition['file'], $module);
}
}
public function testFrameworkAndRunnerScriptsExist(): void
{
foreach ([
'TestContext.php',
'TestRunner.php',
'bootstrap.php',
'bin/affected-modules.php',
'bin/run-symcon-tests.sh',
] as $file) {
self::assertFileExists(__DIR__ . '/Symcon/' . $file);
}
}
public function testAffectedModuleResolverSelectsAllMatches(): void
{
self::assertSame(
['LadestationStandAlone', 'Manager', 'Pufferspeicher', 'VerbraucherEinStufig', 'Warmwassererwaermer'],
$this->resolveAffectedModules(['libs/Nachrichtenvertrag.php'])
);
}
public function testAffectedModuleResolverSelectsManagerForLicenseChecks(): void
{
self::assertSame(
['Manager'],
$this->resolveAffectedModules(['libs/Lizenzpruefung.php'])
);
}
public function testAffectedModuleResolverSelectsAllModulesForFrameworkChanges(): void
{
self::assertSame(
['LadestationStandAlone', 'Manager', 'Pufferspeicher', 'VerbraucherEinStufig', 'Warmwassererwaermer'],
$this->resolveAffectedModules(['tests/Symcon/TestContext.php'])
);
}
/** @param list<string> $paths
* @return list<string>
*/
private function resolveAffectedModules(array $paths): array
{
$argv = array_merge(['affected-modules.php'], $paths);
ob_start();
require __DIR__ . '/Symcon/bin/affected-modules.php';
$output = ob_get_clean();
self::assertIsString($output);
$modules = json_decode($output, true, 512, JSON_THROW_ON_ERROR);
self::assertIsArray($modules);
sort($modules);
return $modules;
}
}
+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,154 @@
<?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, false)
);
}
public function testPeakbetriebBietetBeiFreierSchaltungNurAus(): void
{
self::assertSame(
[0],
EinStufigRegler::leistungsangebot(3500, 0, true, true, false, true)
);
}
public function testTechnischGesperrtesAngebotEnthaeltNurIstleistung(): void
{
self::assertSame(
[3500],
EinStufigRegler::leistungsangebot(3500, 3500, true, false, false, true)
);
self::assertSame(
[3500],
EinStufigRegler::leistungsangebot(3500, 3500, false, true, false, true)
);
}
public function testPeakSperreBeiMindestlaufzeitIstKonfigurierbar(): void
{
self::assertSame(
[0, 3500],
EinStufigRegler::leistungsangebot(3500, 0, true, true, true, true, true)
);
self::assertSame(
[3500],
EinStufigRegler::leistungsangebot(3500, 0, true, true, true, true, false)
);
self::assertSame(
[3500],
EinStufigRegler::leistungsangebot(3500, 0, true, true, true, false, true)
);
}
public function testUngueltigeIstleistungWirdAbgewiesen(): void
{
$this->expectException(InvalidArgumentException::class);
EinStufigRegler::leistungsangebot(3500, 1200, true, true, false, false);
}
public function testFreigabeVerwendetZustandsabhaengigeMindestdauer(): void
{
self::assertSame(1010, EinStufigRegler::freigabezeitpunkt(true, 1000, 10, 20));
self::assertSame(1020, EinStufigRegler::freigabezeitpunkt(false, 1000, 10, 20));
}
public function testRestMindestzeitWirdNieNegativ(): void
{
self::assertSame(7, EinStufigRegler::restMindestzeit(1007, 1000));
self::assertSame(0, EinStufigRegler::restMindestzeit(1000, 1007));
}
public function testNegativeMindestdauerWirdAbgewiesen(): void
{
$this->expectException(InvalidArgumentException::class);
EinStufigRegler::freigabezeitpunkt(true, 1000, -1, 0);
}
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));
}
}
@@ -0,0 +1,191 @@
<?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',
'PeakSperreBeiMindestlaufzeitAnbieten',
'Mindesteinschaltdauer',
'Mindestausschaltdauer',
'DiagnosevariablenAnzeigen',
],
$properties
);
$formular = (string) file_get_contents(self::MODULPFAD . 'form.json');
foreach ($properties as $property) {
self::assertStringContainsString('"name": "' . $property . '"', $formular, $property);
}
}
public function testGetrennteMindestzeitenErsetzenUmschaltabstand(): void
{
$modul = (string) file_get_contents(self::MODULPFAD . 'module.php');
self::assertStringContainsString(
"RegisterPropertyInteger('Mindesteinschaltdauer', 5)",
$modul
);
self::assertStringContainsString(
"RegisterPropertyInteger('Mindestausschaltdauer', 5)",
$modul
);
self::assertStringNotContainsString("RegisterPropertyInteger('Umschaltabstand'", $modul);
self::assertStringNotContainsString("RegisterPropertyInteger('Interval'", $modul);
self::assertStringNotContainsString("RegisterPropertyInteger('IdleCounterMax'", $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 (['MindestzeitAbgelaufen', 'VorgabeTimeout', 'Tagesplanung'] as $timer) {
self::assertStringContainsString("'$timer'", $modul);
}
self::assertStringContainsString(
"SetTimerInterval('Meldezyklus', \$this->ReadPropertyInteger('Meldeintervall') * 1000)",
$modul
);
self::assertStringNotContainsString("case 'Umschaltfreigabe':", $modul);
self::assertStringNotContainsString("RegisterTimer('Regelzyklus'", $modul);
}
public function testRueckmeldungBestimmtZustandUndSchaltbereitschaft(): void
{
$modul = (string) file_get_contents(self::MODULPFAD . 'module.php');
self::assertStringContainsString(
"ReadPropertyInteger('RueckmeldungVariableID')",
$modul
);
self::assertStringContainsString('bestaetigeZustandswechsel', $modul);
self::assertStringContainsString("'Kennung' => 'SchaltbefehlAusstehend'", $modul);
self::assertStringContainsString("'Kennung' => 'Schaltbereit'", $modul);
self::assertStringContainsString("'Kennung' => 'RestMindestzeit_s'", $modul);
self::assertStringContainsString(
'setzeZustand(\'AenderungMoeglich\', $aenderungMoeglich)',
$modul
);
}
public function testModulVerwendetVertrag40UndBetriebsartabhaengigesAngebot(): 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::assertStringContainsString("['Betriebsart']", $modul);
self::assertStringContainsString('BETRIEBSART_PEAK', $modul);
self::assertStringContainsString("'Betriebsart' =>", $modul);
self::assertStringNotContainsString("RegisterVariableString('PowerSteps'", $modul);
self::assertStringNotContainsString("RegisterPropertyInteger('ManagerID'", $modul);
}
public function testFachvariablenUndRueckmeldestatusSindEnthalten(): 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 testManagerKenntDieModulIDGenauEinmal(): void
{
$manager = (string) file_get_contents(__DIR__ . '/../../Manager/module.php');
$modulID = '{15879A4E-D0C2-4495-83DE-46E1E462591E}';
self::assertSame(1, substr_count($manager, $modulID));
}
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);
}
public function testSeparateSuiteIstImGesamttestEnthalten(): void
{
self::assertFileExists(__DIR__ . '/../../phpunit.verbraucher-einstufig.xml');
$composer = json_decode(
(string) file_get_contents(__DIR__ . '/../../composer.json'),
true,
512,
JSON_THROW_ON_ERROR
);
self::assertArrayHasKey('check:verbraucher-einstufig', $composer['scripts']);
$gesamt = (string) file_get_contents(__DIR__ . '/../../phpunit.xml.dist');
self::assertStringContainsString('<directory>tests</directory>', $gesamt);
}
}
+16
View File
@@ -0,0 +1,16 @@
# Tests fuer Verbraucher 1-Stufig
Die Tests des Moduls koennen unabhaengig vom restlichen Repository ausgefuehrt
werden:
```bash
composer check:verbraucher-einstufig
```
Der Befehl prueft nur den Modulcode, die reine Regellogik und diese Tests.
Der allgemeine Befehl bleibt der externe Gesamteinstieg und schliesst die
Modulsuite durch das rekursive Verzeichnis `tests` automatisch ein:
```bash
composer check
```
+150
View File
@@ -0,0 +1,150 @@
<?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 testPeakBietetUnterMinimumUndBeiAktiverUntererHystereseAlleStufen(): void
{
$werte = [0, 1000, 2000];
self::assertSame(
$werte,
WarmwasserRegler::leistungsangebot(true, 44.0, 50.0, 60.0, 5.0, false, $werte)
);
self::assertSame(
$werte,
WarmwasserRegler::leistungsangebot(true, 52.0, 50.0, 60.0, 5.0, true, $werte)
);
self::assertSame(
[0],
WarmwasserRegler::leistungsangebot(true, 52.0, 50.0, 60.0, 5.0, false, $werte)
);
}
public function testPvErzwingtUnterMinimumMaximumUndBietetSonstZustandsabhaengigAn(): void
{
$werte = [0, 1000, 2000];
self::assertSame(
[2000],
WarmwasserRegler::leistungsangebot(false, 44.0, 50.0, 60.0, 5.0, false, $werte)
);
self::assertSame(
$werte,
WarmwasserRegler::leistungsangebot(false, 52.0, 50.0, 60.0, 5.0, false, $werte)
);
self::assertSame(
$werte,
WarmwasserRegler::leistungsangebot(false, 58.0, 50.0, 60.0, 5.0, true, $werte)
);
self::assertSame(
[0],
WarmwasserRegler::leistungsangebot(false, 58.0, 50.0, 60.0, 5.0, false, $werte)
);
}
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);
}
}