Compare commits
53
Commits
main
..
d804ba4bc1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d804ba4bc1 | ||
|
|
e7c546ff2d | ||
|
|
e6516f5d7a | ||
|
|
8ae1f1ea8a | ||
|
|
3c71626b5f | ||
|
|
7b6d1b2f07 | ||
|
|
cf1ae5e348 | ||
|
|
3edf724df7 | ||
|
|
7cf67d4392 | ||
|
|
abd8c7edeb | ||
|
|
312b76aee5 | ||
|
|
4a01e9df0f | ||
|
|
66966875e7 | ||
|
|
51ce9b339e | ||
|
|
37822f0ed0 | ||
|
|
cda579ab86 | ||
|
|
d874ec0a3b | ||
|
|
5c5ef73fb8 | ||
|
|
49fe18c2eb | ||
|
|
052685401f | ||
|
|
ecbd0a3baf | ||
|
|
b404de4095 | ||
|
|
9598a287f8 | ||
|
|
6c2d6ddcde | ||
|
|
d4828f1ee5 | ||
|
|
29cfd8ceaa | ||
|
|
92e8ec59df | ||
|
|
355e7163fb | ||
|
|
1b4a788f22 | ||
|
|
9e6a675b25 | ||
|
|
4514ccb557 | ||
|
|
9869d99641 | ||
|
|
98fb926fbd | ||
|
|
7a3c436039 | ||
|
|
0a3ece3a81 | ||
|
|
21a6466ca5 | ||
|
|
37304691eb | ||
|
|
8af7776f33 | ||
|
|
0c9d4d920a | ||
|
|
dcd310498d | ||
|
|
c1fdd37b2b | ||
|
|
8439213234 | ||
|
|
efda77ccad | ||
|
|
69da93270e | ||
|
|
b2779148ee | ||
|
|
f10d454eb7 | ||
|
|
9426b0f041 | ||
|
|
1541482bf6 | ||
|
|
9acbff610d | ||
|
|
9aab4abdb3 | ||
|
|
0f9860356d | ||
|
|
bd47faa593 | ||
|
|
578285b0d6 |
@@ -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,3 +1,4 @@
|
|||||||
/vendor/
|
/vendor/
|
||||||
/.phpunit.cache/
|
/.phpunit.cache/
|
||||||
/.phpunit.result.cache
|
/.phpunit.result.cache
|
||||||
|
/build/
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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"}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 inklusive Lizenzierung](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)
|
||||||
|
|||||||
@@ -0,0 +1,172 @@
|
|||||||
|
# Verbraucher 1-Stufig
|
||||||
|
|
||||||
|
IP-Symcon-Modul fuer einen elektrischen Verbraucher, der genau zwei
|
||||||
|
Leistungszustaende kennt: aus (`0 W`) und ein (`Nennleistung`).
|
||||||
|
|
||||||
|
Das Modul ist fuer IP-Symcon ab Version 8.0 und den Enelix-Nachrichtenvertrag
|
||||||
|
`4.0` ausgelegt. Es arbeitet ereignisbasiert und besitzt weder einen
|
||||||
|
Regelzyklus noch eine konfigurierbare Zyklusanzahl oder Zykluszeit.
|
||||||
|
|
||||||
|
## Funktionen
|
||||||
|
|
||||||
|
- Schalten eines Boolean-Aktors auf `0 W` oder die konfigurierte Nennleistung
|
||||||
|
- optionale separate Schaltzustands-Rueckmeldung
|
||||||
|
- Mindest-Einschalt- und Mindest-Ausschaltdauer
|
||||||
|
- taegliche Mindestlaufzeit
|
||||||
|
- getrennte Prioritaeten fuer PV- und Peakbetrieb
|
||||||
|
- ereignisbasierte Zustandsmeldung an den Enelix Manager
|
||||||
|
- Diagnosevariablen und optionales Debug-Logging
|
||||||
|
- sichere lokale Deaktivierung ueber die Variable `Aktiv`
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
1. Im IP-Symcon Module Control die Bibliothek
|
||||||
|
`https://git.belevo.ch/ENELIX/Enelix-EMS.git` installieren.
|
||||||
|
2. Fuer Entwicklung und Tests den Kanal beziehungsweise Branch `develop`
|
||||||
|
verwenden.
|
||||||
|
3. Unter **Instanz hinzufuegen** nach **Verbraucher 1-Stufig** suchen.
|
||||||
|
4. Eine Instanz anlegen und mindestens Nennleistung und Schaltkontakt
|
||||||
|
konfigurieren.
|
||||||
|
|
||||||
|
## Konfiguration
|
||||||
|
|
||||||
|
| Einstellung | Standard | Beschreibung |
|
||||||
|
| --- | ---: | --- |
|
||||||
|
| `PrioritaetPV` | `0` | Reihenfolge bei der PV-Leistungsverteilung. |
|
||||||
|
| `PrioritaetPeak` | `0` | Reihenfolge bei der Lastspitzenregelung. |
|
||||||
|
| `Meldeintervall` | `10 s` | Periodische Vollmeldung an zugeordnete Manager. |
|
||||||
|
| `VorgabeTimeout` | `120 s` | Gueltigkeitsdauer einer Manager-Vorgabe. |
|
||||||
|
| `Mindesteinschaltdauer` | `5 s` | Mindestdauer eines bestaetigten Ein-Zustands. |
|
||||||
|
| `Mindestausschaltdauer` | `5 s` | Mindestdauer eines bestaetigten Aus-Zustands. |
|
||||||
|
| `Nennleistung` | `0 W` | Leistungsaufnahme im eingeschalteten Zustand. |
|
||||||
|
| `SchaltkontaktVariableID` | `0` | Booleanvariable des zu schaltenden Aktors. |
|
||||||
|
| `SchaltkontaktInvertiert` | `false` | Invertiert die Aktorlogik. |
|
||||||
|
| `RueckmeldungVariableID` | `0` | Optionale Booleanvariable fuer den physischen Zustand. |
|
||||||
|
| `Mindestlaufzeit` | `0 s` | Geforderte Laufzeit pro lokalem Kalendertag. |
|
||||||
|
| `PeakSperreBeiMindestlaufzeitAnbieten` | `true` | Erlaubt im Peakbetrieb eine Sperre trotz faelliger Tagesmindestlaufzeit. |
|
||||||
|
| `DiagnosevariablenAnzeigen` | `false` | Blendet technische Diagnosevariablen ein. |
|
||||||
|
| `LoggingEin` | `false` | Aktiviert zusaetzliche Debug-Ausgaben. |
|
||||||
|
|
||||||
|
`Nennleistung` muss groesser als `0` sein. Der Schaltkontakt muss eine
|
||||||
|
Booleanvariable mit funktionsfaehiger Standard- oder benutzerdefinierter
|
||||||
|
Aktion sein. Beide Mindestzeiten duerfen auf `0` gesetzt werden.
|
||||||
|
|
||||||
|
Der fruehere allgemeine `Umschaltabstand` sowie Properties fuer Zyklusanzahl
|
||||||
|
und Zykluszeit existieren nicht.
|
||||||
|
|
||||||
|
## Verhalten ohne separate Rueckmeldung
|
||||||
|
|
||||||
|
Ist keine `RueckmeldungVariableID` konfiguriert, wird die Aktorvariable als
|
||||||
|
unmittelbare Schaltbestaetigung verwendet.
|
||||||
|
|
||||||
|
Nach einem erfolgreichen Schaltbefehl beginnt ab dem uebernommenen
|
||||||
|
Aktorzustand:
|
||||||
|
|
||||||
|
- beim Einschalten die `Mindesteinschaltdauer`,
|
||||||
|
- beim Ausschalten die `Mindestausschaltdauer`.
|
||||||
|
|
||||||
|
Uebernimmt die Aktorvariable den angeforderten Wert nicht, meldet das Modul
|
||||||
|
einen Schaltfehler und stellt sich dem Manager nicht als schaltbereit dar.
|
||||||
|
|
||||||
|
## Verhalten mit separater Rueckmeldung
|
||||||
|
|
||||||
|
Ist eine `RueckmeldungVariableID` konfiguriert, bestimmt ausschliesslich deren
|
||||||
|
Booleanwert den bestaetigten Schaltzustand. `true` muss dabei physisch
|
||||||
|
eingeschaltet bedeuten.
|
||||||
|
|
||||||
|
Zwischen Aktorbefehl und passender Rueckmeldung meldet das Modul:
|
||||||
|
|
||||||
|
- den bisherigen bestaetigten Schaltzustand und die bisherige Istleistung,
|
||||||
|
- `SchaltbefehlAusstehend=true`,
|
||||||
|
- `Schaltbereit=false`,
|
||||||
|
- `AenderungMoeglich=false`.
|
||||||
|
|
||||||
|
Die jeweilige Mindestzeit beginnt erst, sobald die Rueckmeldung den neuen
|
||||||
|
Zustand bestaetigt. Bleibt die Rueckmeldung aus, bleibt der Schaltbefehl
|
||||||
|
sichtbar ausstehend. Eine abweichende Rueckmeldung ohne laufenden
|
||||||
|
Schaltvorgang wird als Rueckmeldefehler gemeldet.
|
||||||
|
|
||||||
|
## Mindestzeiten und Leistungsangebot
|
||||||
|
|
||||||
|
Waerend einer Mindest-Ein- oder Mindest-Aus-Zeit bietet das Modul nur die
|
||||||
|
bestaetigte aktuelle Leistung an. Ein regulaerer Lastwechsel ist erst nach
|
||||||
|
Ablauf der Mindestzeit wieder moeglich.
|
||||||
|
|
||||||
|
Der Manager erhaelt dazu unter anderem:
|
||||||
|
|
||||||
|
| Zustand | Bedeutung |
|
||||||
|
| --- | --- |
|
||||||
|
| `Schaltzustand` | Bestaetigter Ein-/Aus-Zustand. |
|
||||||
|
| `SchaltbefehlAusstehend` | Aktorbefehl wartet auf physische Bestaetigung. |
|
||||||
|
| `Schaltbereit` | Ein weiterer regulaerer Lastwechsel ist moeglich. |
|
||||||
|
| `RestMindestzeit_s` | Verbleibende Mindestzeit in Sekunden. |
|
||||||
|
| `Tageslaufzeit_s` | Bestaetigte Laufzeit des aktuellen Tages. |
|
||||||
|
| `Rueckmeldefehler` | Aktor und Rueckmeldung widersprechen sich unerwartet. |
|
||||||
|
|
||||||
|
Die lokale Aktion `Aktiv=false` ist ein bewusster Sicherheits-Override. Sie
|
||||||
|
schaltet den Verbraucher auch waehrend einer laufenden
|
||||||
|
Mindesteinschaltdauer aus.
|
||||||
|
|
||||||
|
## Sichtbare Variablen
|
||||||
|
|
||||||
|
Immer vorhanden sind:
|
||||||
|
|
||||||
|
- `Aktiv`: lokale Freigabe fuer das Energiemanagement
|
||||||
|
- `Schaltzustand`: bestaetigter oder aus dem Aktor abgeleiteter Zustand
|
||||||
|
- `Tageslaufzeit`: bestaetigte Laufzeit des aktuellen Tages in Sekunden
|
||||||
|
|
||||||
|
Bei aktivierter Diagnose werden zusaetzlich Soll- und Istleistung,
|
||||||
|
Verfuegbarkeit, Schaltbereitschaft, Stoerung, Rueckmeldefehler,
|
||||||
|
ausstehender Schaltbefehl und verbleibende Mindestzeit angezeigt.
|
||||||
|
|
||||||
|
## Inbetriebnahme
|
||||||
|
|
||||||
|
1. Nennleistung und Schaltkontakt konfigurieren.
|
||||||
|
2. Falls vorhanden, die separate Rueckmeldung auswaehlen und ihre
|
||||||
|
`true`-Semantik pruefen.
|
||||||
|
3. Mindest-Ein- und Mindest-Ausschaltdauer passend zum angeschlossenen
|
||||||
|
Geraet festlegen.
|
||||||
|
4. Den Verbraucher im Enelix Manager manuell oder automatisch aktiv
|
||||||
|
zuordnen.
|
||||||
|
5. Fuer die Erstpruefung Diagnosevariablen und bei Bedarf Logging aktivieren.
|
||||||
|
6. Die Variable `Aktiv` einschalten.
|
||||||
|
7. Unter Aufsicht je eine Ein- und Aus-Vorgabe durch den Manager ausfuehren.
|
||||||
|
8. Kontrollieren, dass Rueckmeldung, Mindestzeiten und `Schaltbereit`
|
||||||
|
erwartungsgemaess wechseln.
|
||||||
|
|
||||||
|
## Fehlersuche
|
||||||
|
|
||||||
|
- **Konfiguration ungueltig:** Nennleistung, Variablentyp und Aktoraktion
|
||||||
|
pruefen.
|
||||||
|
- **Schaltbefehl bleibt ausstehend:** Separate Rueckmeldung und deren
|
||||||
|
`true`-Semantik pruefen.
|
||||||
|
- **Rueckmeldefehler:** Aktor- und Rueckmeldewert stimmen ausserhalb eines
|
||||||
|
laufenden Schaltvorgangs nicht ueberein.
|
||||||
|
- **Kein Lastwechsel moeglich:** `RestMindestzeit`, `Aktiv`,
|
||||||
|
`SollwertGueltig` und die Managerzuordnung kontrollieren.
|
||||||
|
- **Keine Manager-Vorgabe akzeptiert:** Der Verbraucher muss beim sendenden
|
||||||
|
Manager aktiv zugeordnet sein.
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
Nur dieses Modul:
|
||||||
|
|
||||||
|
~~~bash
|
||||||
|
composer check:verbraucher-einstufig
|
||||||
|
~~~
|
||||||
|
|
||||||
|
Gesamtes Repository inklusive dieser Testsuite:
|
||||||
|
|
||||||
|
~~~bash
|
||||||
|
composer check
|
||||||
|
~~~
|
||||||
|
|
||||||
|
Die Modulsuite kann damit unabhaengig weiterentwickelt werden und bleibt
|
||||||
|
gleichzeitig Bestandteil des allgemeinen Tests.
|
||||||
|
|
||||||
|
## Weiterfuehrende Dokumentation
|
||||||
|
|
||||||
|
- [Ausfuehrliche Modulbeschreibung](../docs/module/Verbraucher-1-Stufig/README.md)
|
||||||
|
- [Manager-Verbraucher-Schnittstelle](../docs/Schnittstelle.md)
|
||||||
|
- [Migration von Enelix 1](../docs/migration/Verbraucher-1-Stufig.md)
|
||||||
|
- [Aufbau der separaten Testsuite](../tests/VerbraucherEinStufig/README.md)
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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
@@ -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": {
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -0,0 +1,202 @@
|
|||||||
|
# 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.
|
||||||
|
|
||||||
|
## Lizenzierung
|
||||||
|
|
||||||
|
Der Manager arbeitet nur mit einer gueltigen Manager-Lizenz. Das Lizenzfeld steht
|
||||||
|
zuoberst im Konfigurationsformular. Ohne Freigabe bleibt die Instanz mit Status
|
||||||
|
`203` inaktiv und sendet keine Leistungsvorgaben.
|
||||||
|
|
||||||
|
### Voraussetzungen
|
||||||
|
|
||||||
|
- Der Auftrag im Enelix-Lizenzportal ist bezahlt und enthaelt eine aktive
|
||||||
|
Manager-Berechtigung.
|
||||||
|
- IP-Symcon erreicht `https://license.enelix.ch` ueber HTTPS (Port 443).
|
||||||
|
- Der Lizenzcode liegt im Format `ENX-XXXX-XXXX-XXXX-XXXX` vor.
|
||||||
|
|
||||||
|
### Lizenz aktivieren
|
||||||
|
|
||||||
|
1. Manager-Konfiguration in IP-Symcon oeffnen.
|
||||||
|
2. Lizenzcode im Bereich `Lizenzierung` eintragen.
|
||||||
|
3. `Lizenz pruefen und binden` ausloesen.
|
||||||
|
4. Die erfolgreiche Freigabe am angezeigten Lizenzstatus kontrollieren.
|
||||||
|
5. Die Manager-Konfiguration mit `Uebernehmen` beziehungsweise `OK` speichern,
|
||||||
|
damit der eingegebene Lizenzcode als Property erhalten bleibt.
|
||||||
|
|
||||||
|
Beim ersten Anlegen erzeugt die Manager-Instanz eine UUIDv4 als stabile
|
||||||
|
Installations-ID. Die Aktivierung sendet ausschliesslich `code` und
|
||||||
|
`installationId` per
|
||||||
|
`POST https://license.enelix.ch/api/v1/licenses/activate`. Sie benoetigt weder
|
||||||
|
Portal-Cookies noch einen CSRF-Token. Derselbe Code kann von derselben
|
||||||
|
Installation erneut abgerufen werden; die Bindung an eine andere Installation
|
||||||
|
wird vom Lizenzserver abgelehnt.
|
||||||
|
|
||||||
|
### Berechtigungen
|
||||||
|
|
||||||
|
| Berechtigung | Freigegebene Funktion |
|
||||||
|
| --- | --- |
|
||||||
|
| `manager_standard` | Manager-Grundregelung ohne Lastspitzenmodus. |
|
||||||
|
| `manager_peak` | Manager-Grundregelung einschliesslich konstantem oder monatlichem Lastspitzenmodus. |
|
||||||
|
|
||||||
|
Wird mit `manager_standard` ein Lastspitzenmodus aktiviert, bleibt der Manager
|
||||||
|
mit dem Hinweis `Peak Shaving ist nicht lizenziert` gesperrt. `manager_peak`
|
||||||
|
gilt zugleich als Berechtigung fuer die Grundregelung.
|
||||||
|
|
||||||
|
### Erneuerung und Offline-Betrieb
|
||||||
|
|
||||||
|
Die erfolgreiche Serverantwort wird als Lease in der Manager-Instanz gespeichert.
|
||||||
|
Der Manager erneuert sie ab `refreshAfter` automatisch ueber denselben
|
||||||
|
Aktivierungsendpunkt. Schlaegt eine Erneuerung fehl, wird fruehestens nach einer
|
||||||
|
Stunde erneut angefragt. Eine bereits bestaetigte Entwicklungsfreigabe bleibt
|
||||||
|
bis `offlineUntil` verwendbar. Der aktuelle Entwicklungsvertrag setzt diesen
|
||||||
|
Zeitpunkt ungefaehr 14 Tage nach Ausstellung. Danach sperrt der Manager die
|
||||||
|
Regelung, bis der Lizenzserver wieder eine gueltige Antwort liefert.
|
||||||
|
|
||||||
|
Die Installations-ID und die Lease liegen in internen Instanzattributen. Bei
|
||||||
|
einer Migration muss deshalb die vollstaendige Manager-Instanz mitsamt ihren
|
||||||
|
Attributen uebernommen werden. Eine neu erzeugte Instanz erhaelt eine andere
|
||||||
|
Installations-ID und kann einen bereits gebundenen Code nicht selbststaendig
|
||||||
|
uebertragen.
|
||||||
|
|
||||||
|
### Status und Fehlerbehebung
|
||||||
|
|
||||||
|
| Anzeige / Serverstatus | Bedeutung und Massnahme |
|
||||||
|
| --- | --- |
|
||||||
|
| `Lizenzcode fehlt.` | Code eintragen, pruefen und die Konfiguration speichern. |
|
||||||
|
| `Lizenzcode ist ungueltig.` | Format und Zeichen des Codes kontrollieren. |
|
||||||
|
| HTTP `404` | Code unbekannt oder zugehoeriger Auftrag noch nicht bezahlt. |
|
||||||
|
| HTTP `409` | Code ist bereits an eine andere Installation gebunden. |
|
||||||
|
| HTTP `429` | Zu viele Aktivierungsversuche; vor dem naechsten Versuch warten. |
|
||||||
|
| `Lizenzserver nicht erreichbar` | DNS, Internetzugang, HTTPS und Systemzeit des Symcon-Systems pruefen. Eine bestehende Lease gilt nur bis `offlineUntil`. |
|
||||||
|
| `Offline-Freigabe ist abgelaufen.` | Verbindung zum Lizenzserver wiederherstellen und Lizenz erneut pruefen. |
|
||||||
|
|
||||||
|
Fuer eine genauere Diagnose koennen die Diagnosevariablen eingeblendet werden.
|
||||||
|
`Lizenzstatus` zeigt dann den aktuellen Zustand. Mit aktiviertem Debug-Logging
|
||||||
|
werden Fehlermeldungen der Lizenzpruefung ausgegeben, niemals jedoch der
|
||||||
|
Lizenzcode selbst.
|
||||||
|
|
||||||
|
### Datenschutz und Entwicklungsstand
|
||||||
|
|
||||||
|
Der Lizenzcode wird als Manager-Property in der IP-Symcon-Konfiguration
|
||||||
|
gespeichert. Fuer den internen Abgleich mit der Lease verwendet der Manager
|
||||||
|
zusaetzlich nur einen SHA-256-Hash und schreibt den Code nicht ins Debug-Log.
|
||||||
|
Die aktuelle Serverantwort ist ein Entwicklungsvertrag mit
|
||||||
|
`development: true` und noch nicht kryptografisch signiert. Ein optionaler
|
||||||
|
Geraete-Public-Key sowie Challenge-, Heartbeat- oder separate
|
||||||
|
Entitlement-Endpunkte werden vom Manager derzeit bewusst nicht 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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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).
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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 ? '→' : ($grid > 25 ? '←' : '↔');
|
||||||
|
$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">→</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
@@ -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"
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
<?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::assertStringContainsStringIgnoringCase('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
|
||||||
|
{
|
||||||
|
$detailInhalt = file_get_contents(
|
||||||
|
__DIR__ . '/../docs/module/Verbraucher-1-Stufig/README.md'
|
||||||
|
);
|
||||||
|
self::assertNotFalse($detailInhalt);
|
||||||
|
self::assertStringContainsString('Status: implementiert', $detailInhalt);
|
||||||
|
self::assertStringContainsString('Umschaltabstand', $detailInhalt);
|
||||||
|
self::assertStringContainsString('Installation und Inbetriebnahme', $detailInhalt);
|
||||||
|
|
||||||
|
$modulInhalt = file_get_contents(__DIR__ . '/../VerbraucherEinStufig/README.md');
|
||||||
|
self::assertNotFalse($modulInhalt);
|
||||||
|
self::assertStringContainsString('Verhalten ohne separate Rueckmeldung', $modulInhalt);
|
||||||
|
self::assertStringContainsString('Mindesteinschaltdauer', $modulInhalt);
|
||||||
|
self::assertStringContainsString(
|
||||||
|
'composer check:verbraucher-einstufig',
|
||||||
|
$modulInhalt
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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']);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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',
|
||||||
|
]],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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' => [
|
||||||
|
|||||||
@@ -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}'
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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}.");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
Executable
+73
@@ -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 ]]
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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.');
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -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)
|
||||||
|
));
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -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]);
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
```
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user