4 Commits
Author SHA1 Message Date
dh 693beb2263 docs: Projektstatus und Einrichtungsentscheidung aktualisieren
Tests / test (push) Successful in 49s
2026-09-22 14:43:36 +00:00
dh b1cb289922 docs: Ladestation Stand-Alone vollständig dokumentieren 2026-09-22 14:43:36 +00:00
dh 98ee569178 feat: Demoanlage um Batterie und Tagesprofile erweitern 2026-09-22 14:43:36 +00:00
dh 2392a9c209 feat: Lizenzkontingente im Manager durchsetzen 2026-09-22 14:43:36 +00:00
13 changed files with 1395 additions and 194 deletions
+8 -1
View File
@@ -26,6 +26,13 @@
"name": "LizenzPruefen",
"caption": "Lizenz pruefen und binden",
"onClick": "IPS_RequestAction($id, 'FormLizenzPruefen', $Lizenzcode);"
},
{
"type": "Button",
"name": "LizenzVerwalten",
"caption": "Lizenz verwalten",
"link": true,
"onClick": "echo 'https://license.enelix.ch';"
}
]
},
@@ -296,6 +303,6 @@
{"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"}
{"code": 203, "icon": "error", "caption": "Lizenz nicht freigegeben oder Verbraucherkontingent ueberschritten"}
]
}
+434 -26
View File
@@ -21,14 +21,35 @@ class Manager extends IPSModule implements ManagerSchnittstelle
private const LIZENZ_ENDPOINT = 'https://license.enelix.ch/api/v1/licenses/activate';
private const LIZENZ_RETRY_SEKUNDEN = 3600;
private const VM_UPDATE = 10603;
private const VERBRAUCHER_MODUL_IDS = [
'{B7C54AF4-AD7D-4FE4-B75D-203693906251}',
'{15879A4E-D0C2-4495-83DE-46E1E462591E}',
'{A31C9274-54F7-4BD2-9804-7AF23E86C4D1}',
'{0D94913C-0F31-4C29-A685-6EB4AE58E55D}',
'{32F65988-BE55-4FD6-9FFF-CF1C4C8628F8}',
'{C92D5EEF-9632-47A5-9659-4B02BF40FBE9}',
'{437FB683-517F-4FEC-8CCB-FE6B0A62B69E}',
private const VERBRAUCHER_LIZENZEN = [
'{B7C54AF4-AD7D-4FE4-B75D-203693906251}' => [
'Katalog' => 'boiler_multi',
'Name' => 'Boiler mehrstufig',
],
'{15879A4E-D0C2-4495-83DE-46E1E462591E}' => [
'Katalog' => 'consumer_single',
'Name' => 'Verbraucher einstufig',
],
'{A31C9274-54F7-4BD2-9804-7AF23E86C4D1}' => [
'Katalog' => 'consumer_single',
'Name' => 'Waermepumpe',
],
'{0D94913C-0F31-4C29-A685-6EB4AE58E55D}' => [
'Katalog' => 'ev_charger',
'Name' => 'EV-Ladestation Stand-Alone',
],
'{32F65988-BE55-4FD6-9FFF-CF1C4C8628F8}' => [
'Katalog' => 'ev_charger',
'Name' => 'EV-Ladestation Gateway',
],
'{C92D5EEF-9632-47A5-9659-4B02BF40FBE9}' => [
'Katalog' => 'buffer_storage',
'Name' => 'Pufferspeicher',
],
'{437FB683-517F-4FEC-8CCB-FE6B0A62B69E}' => [
'Katalog' => 'battery',
'Name' => 'Batteriespeicher',
],
];
private const MONATSNAMEN = [
1 => 'Januar', 'Februar', 'Maerz', 'April', 'Mai', 'Juni',
@@ -41,6 +62,7 @@ class Manager extends IPSModule implements ManagerSchnittstelle
'VerbraucherAnzahl',
'VerbraucherVerfuegbar',
'Verbraucherstatus',
'NichtGeregelteGeraete',
'Monatsgrenzen',
'Prognosestatus',
'SDLStatus',
@@ -90,6 +112,7 @@ class Manager extends IPSModule implements ManagerSchnittstelle
$this->RegisterAttributeString('LizenzLease', '{}');
$this->RegisterAttributeString('LizenzcodeHash', '');
$this->RegisterAttributeInteger('LizenzNaechsterVersuch', 0);
$this->RegisterAttributeInteger('LizenzBerechtigungenNaechsterVersuch', 0);
$this->RegisterAttributeString('LizenzLetzterFehler', '');
if ($this->ReadAttributeString('LizenzInstallationID') === '') {
$this->WriteAttributeString('LizenzInstallationID', $this->erzeugeUuidV4());
@@ -133,6 +156,14 @@ class Manager extends IPSModule implements ManagerSchnittstelle
$this->SetStatus(self::STATUS_LIZENZ_UNGUELTIG);
return;
}
try {
$this->aktiveVerbraucherIDs();
} catch (Throwable $fehler) {
$this->SetValue('Betriebsart', ManagerRegler::BETRIEBSART_INAKTIV);
$this->setzeDiagnosewert('Lizenzstatus', 'Nicht freigegeben: ' . $fehler->getMessage());
$this->SetStatus(self::STATUS_LIZENZ_UNGUELTIG);
return;
}
$this->SetStatus(self::STATUS_AKTIV);
}
@@ -146,7 +177,9 @@ class Manager extends IPSModule implements ManagerSchnittstelle
);
$automatisch = $this->ReadPropertyBoolean('AutomatischeSuche');
$lastspitzenmodus = $this->ReadPropertyInteger('Lastspitzenmodus');
$monatsgrenzenSichtbar = $lastspitzenmodus === 2
$peakShavingLizenziert = $this->peakShavingLizenziert();
$monatsgrenzenSichtbar = $peakShavingLizenziert
&& $lastspitzenmodus === 2
&& $this->GetBuffer('MonatsgrenzenSichtbar') === '1';
$this->setzeFormularParameter(
@@ -181,17 +214,24 @@ class Manager extends IPSModule implements ManagerSchnittstelle
'values',
$this->automatischeVerbraucherFuerFormular()
);
$lastspitzenOptionen = $this->lastspitzenOptionen($peakShavingLizenziert);
$this->setzeFormularParameter(
$formular['elements'],
'Lastspitzenmodus',
'options',
$lastspitzenOptionen
);
$this->setzeFormularParameter(
$formular['elements'],
'Lastspitzengrenze',
'visible',
$lastspitzenmodus === 1
$peakShavingLizenziert && $lastspitzenmodus === 1
);
$this->setzeFormularParameter(
$formular['elements'],
'MonatsgrenzenUmschalten',
'visible',
$lastspitzenmodus === 2
$peakShavingLizenziert && $lastspitzenmodus === 2
);
$this->setzeFormularParameter(
$formular['elements'],
@@ -242,6 +282,7 @@ class Manager extends IPSModule implements ManagerSchnittstelle
}
$this->aktualisiereLizenzfreigabe($wert, true);
$this->UpdateFormField('LizenzInformation', 'caption', $this->lizenzStatustext($wert));
$this->aktualisiereLastspitzenformular();
return;
case 'FormAutomatischeSuche':
@@ -277,6 +318,14 @@ class Manager extends IPSModule implements ManagerSchnittstelle
case 'FormLastspitzenmodus':
$modus = (int) $wert;
if ($modus !== 0 && !$this->peakShavingLizenziert()) {
$this->UpdateFormField('Lastspitzenmodus', 'value', 0);
$this->UpdateFormField('Lastspitzengrenze', 'visible', false);
$this->UpdateFormField('MonatsgrenzenUmschalten', 'visible', false);
throw new InvalidArgumentException(
'Konstant und Monatlich benoetigen eine Peak-Shaving-Lizenz.'
);
}
$this->UpdateFormField('Lastspitzengrenze', 'visible', $modus === 1);
$this->UpdateFormField('MonatsgrenzenUmschalten', 'visible', $modus === 2);
if ($modus !== 2) {
@@ -346,6 +395,17 @@ class Manager extends IPSModule implements ManagerSchnittstelle
if (!$this->aktualisiereLizenzfreigabe($this->ReadPropertyString('Lizenzcode'), false)) {
$this->SetValue('Betriebsart', ManagerRegler::BETRIEBSART_INAKTIV);
$this->setzeDiagnosewert('Verteilbudget', 0.0);
$this->setzeNichtGeregelteVerbraucher('Lizenz nicht freigegeben');
$this->SetStatus(self::STATUS_LIZENZ_UNGUELTIG);
return;
}
try {
$this->aktiveVerbraucherIDs();
} catch (Throwable $fehler) {
$this->SetValue('Betriebsart', ManagerRegler::BETRIEBSART_INAKTIV);
$this->setzeDiagnosewert('Verteilbudget', 0.0);
$this->setzeDiagnosewert('Lizenzstatus', 'Nicht freigegeben: ' . $fehler->getMessage());
$this->setzeNichtGeregelteVerbraucher($fehler->getMessage());
$this->SetStatus(self::STATUS_LIZENZ_UNGUELTIG);
return;
}
@@ -358,6 +418,9 @@ class Manager extends IPSModule implements ManagerSchnittstelle
if (!$aktiv || !$netzleistungGueltig) {
$this->SetValue('Betriebsart', ManagerRegler::BETRIEBSART_INAKTIV);
$this->setzeDiagnosewert('Verteilbudget', 0.0);
$this->setzeNichtGeregelteVerbraucher(
$aktiv ? 'Netzleistungsmessung fehlt oder ist veraltet' : 'Manager ist deaktiviert'
);
$this->SetStatus($aktiv ? self::STATUS_MESSWERT_UNGUELTIG : self::STATUS_AKTIV);
return;
}
@@ -385,8 +448,14 @@ class Manager extends IPSModule implements ManagerSchnittstelle
}
$stoerungen = [];
$nichtGeregelt = [];
$synchronisiert = true;
$verbraucher = $this->aktuelleVerbraucher($stoerungen, $betriebsart, $synchronisiert);
$verbraucher = $this->aktuelleVerbraucher(
$stoerungen,
$nichtGeregelt,
$betriebsart,
$synchronisiert
);
$this->setzeDiagnosewert('WirksameLastspitzengrenze', $lastspitzengrenze ?? 0.0);
if (!$synchronisiert) {
$this->setzeDiagnosewert('Verteilbudget', 0.0);
@@ -394,7 +463,12 @@ class Manager extends IPSModule implements ManagerSchnittstelle
'Betriebsart' => $betriebsart,
'Synchronisiert' => false,
'Verbraucher' => $verbraucher,
'NichtGeregelt' => $nichtGeregelt,
], JSON_THROW_ON_ERROR));
$this->setzeDiagnosewert(
'NichtGeregelteGeraete',
$this->nichtGeregelteGeraeteText($nichtGeregelt)
);
$this->aktualisiereStoerungen($verbraucher, $stoerungen);
$this->SetStatus(self::STATUS_AKTIV);
return;
@@ -415,7 +489,12 @@ class Manager extends IPSModule implements ManagerSchnittstelle
'Synchronisiert' => true,
'Restabweichung_W' => $ergebnis['Restabweichung_W'],
'Verbraucher' => $verbraucher,
'NichtGeregelt' => [],
], JSON_THROW_ON_ERROR));
$this->setzeDiagnosewert(
'NichtGeregelteGeraete',
'Alle aktiven Verbraucher werden geregelt.'
);
$letzteSollwerte = $this->leseJsonAttribut('LetzteSollwerte');
foreach ($ergebnis['Sollwerte'] as $instanzID => $sollleistung) {
@@ -457,10 +536,12 @@ class Manager extends IPSModule implements ManagerSchnittstelle
/**
* @param list<string> $stoerungen
* @param list<array<string, mixed>> $nichtGeregelt
* @return list<array{InstanzID: int, Daten: array<string, mixed>}>
*/
private function aktuelleVerbraucher(
array &$stoerungen,
array &$nichtGeregelt,
string $betriebsart,
bool &$synchronisiert
): array {
@@ -483,11 +564,14 @@ class Manager extends IPSModule implements ManagerSchnittstelle
if ($grund !== '') {
$synchronisiert = false;
$stoerungen[] = $grund . ' von Instanz ' . $instanzID . '.';
$detail = $this->verbraucherDetail($instanzID, $grund, $eintrag);
$nichtGeregelt[] = $detail;
$stoerungen[] = $detail['Name'] . ': ' . $grund . '.';
try {
$this->sendeManagerdaten($instanzID, $betriebsart, null);
} catch (Throwable $fehler) {
$stoerungen[] = 'Betriebsart an Instanz ' . $instanzID . ': ' . $fehler->getMessage();
$stoerungen[] = $detail['Name'] . ': Betriebsart konnte nicht uebermittelt werden: '
. $fehler->getMessage();
}
continue;
}
@@ -500,8 +584,124 @@ class Manager extends IPSModule implements ManagerSchnittstelle
return $aktuell;
}
private function setzeNichtGeregelteVerbraucher(string $grund): void
{
try {
$ids = $this->konfigurierteAktiveVerbraucherIDs();
} catch (Throwable $fehler) {
$ids = [];
$grund .= '; Verbraucherzuordnung ungueltig: ' . $fehler->getMessage();
}
$cache = $this->leseJsonAttribut('VerbraucherCache');
$nichtGeregelt = [];
$stoerungen = [];
foreach ($ids as $instanzID) {
$detail = $this->verbraucherDetail(
$instanzID,
$grund,
$cache[(string) $instanzID] ?? null
);
$nichtGeregelt[] = $detail;
$stoerungen[] = $detail['Name'] . ': ' . $grund . '.';
}
$this->setzeDiagnosewert('VerbraucherAnzahl', count($ids));
$this->setzeDiagnosewert('VerbraucherVerfuegbar', 0);
$this->setzeDiagnosewert('Verbraucherstatus', json_encode([
'Betriebsart' => ManagerRegler::BETRIEBSART_INAKTIV,
'Synchronisiert' => false,
'Verbraucher' => [],
'NichtGeregelt' => $nichtGeregelt,
], JSON_THROW_ON_ERROR));
$this->setzeDiagnosewert(
'NichtGeregelteGeraete',
$this->nichtGeregelteGeraeteText($nichtGeregelt)
);
$this->aktualisiereStoerungen([], $stoerungen);
}
/**
* @param array<string, mixed>|null $cacheEintrag
* @return array<string, mixed>
*/
private function verbraucherDetail(
int $instanzID,
string $grund,
?array $cacheEintrag
): array {
$name = 'Instanz ' . $instanzID;
$typ = 'Unbekannter Verbraucher';
$instanzStatus = null;
if (IPS_InstanceExists($instanzID)) {
$name = IPS_GetName($instanzID);
$instanz = IPS_GetInstance($instanzID);
$instanzStatus = (int) ($instanz['InstanceStatus'] ?? 0);
$modulID = strtoupper((string) ($instanz['ModuleInfo']['ModuleID'] ?? ''));
$typ = self::VERBRAUCHER_LIZENZEN[$modulID]['Name'] ?? $typ;
}
$daten = is_array($cacheEintrag['Daten'] ?? null)
? $cacheEintrag['Daten']
: [];
$empfangenAm = is_int($cacheEintrag['EmpfangenAm'] ?? null)
? $cacheEintrag['EmpfangenAm']
: null;
return [
'InstanzID' => $instanzID,
'Name' => $name,
'Typ' => $typ,
'Grund' => $grund,
'InstanzStatus' => $instanzStatus,
'LetzteMeldung' => $empfangenAm !== null ? date(DATE_ATOM, $empfangenAm) : null,
'GemeldeteBetriebsart' => is_string($daten['Betriebsart'] ?? null)
? $daten['Betriebsart']
: null,
'Verfuegbar' => is_bool($daten['Verfuegbar'] ?? null)
? $daten['Verfuegbar']
: null,
];
}
/**
* @param list<array<string, mixed>> $nichtGeregelt
*/
private function nichtGeregelteGeraeteText(array $nichtGeregelt): string
{
if ($nichtGeregelt === []) {
return 'Alle aktiven Verbraucher werden geregelt.';
}
$zeilen = [];
foreach ($nichtGeregelt as $detail) {
$teile = [
(string) ($detail['Name'] ?? 'Unbekanntes Geraet'),
(string) ($detail['Typ'] ?? 'Unbekannter Typ'),
(string) ($detail['Grund'] ?? 'Grund unbekannt'),
];
if (is_string($detail['LetzteMeldung'] ?? null)) {
$teile[] = 'letzte Meldung: ' . $detail['LetzteMeldung'];
}
if (is_bool($detail['Verfuegbar'] ?? null)) {
$teile[] = 'verfuegbar: ' . ($detail['Verfuegbar'] ? 'ja' : 'nein');
}
$zeilen[] = implode(' | ', $teile);
}
return implode("\n", $zeilen);
}
/** @return list<int> */
private function aktiveVerbraucherIDs(): array
{
$ids = $this->konfigurierteAktiveVerbraucherIDs();
$this->pruefeVerbraucherlizenzen($ids);
return $ids;
}
/** @return list<int> */
private function konfigurierteAktiveVerbraucherIDs(): array
{
if (!$this->ReadPropertyBoolean('AutomatischeSuche')) {
return $this->leseAktiveVerbraucherIDs('VerbraucherZuordnung');
@@ -521,6 +721,64 @@ class Manager extends IPSModule implements ManagerSchnittstelle
return $this->leseAktiveVerbraucherIDs('AutomatischeVerbraucherZuordnung');
}
/**
* @param list<int> $ids
*/
private function pruefeVerbraucherlizenzen(array $ids): void
{
[$mengen, $namen] = $this->verbraucherLizenzbelegung($ids);
try {
Lizenzpruefung::pruefeVerbrauchermengen($this->leseLizenzLease(), $mengen);
} catch (InvalidArgumentException $fehler) {
foreach ($mengen as $kennung => $verwendet) {
$lizenziert = Lizenzpruefung::berechtigungsMenge(
$this->leseLizenzLease(),
$kennung
);
if ($verwendet > $lizenziert) {
throw new InvalidArgumentException(sprintf(
'%s: %d aktiviert, aber nur %d lizenziert. Bitte Auswahl reduzieren oder Lizenz erweitern.',
$namen[$kennung],
$verwendet,
$lizenziert
), 0, $fehler);
}
}
throw $fehler;
}
}
/**
* @param list<int> $ids
* @return array{0: array<string, int>, 1: array<string, string>}
*/
private function verbraucherLizenzbelegung(array $ids): array
{
$mengen = [];
$namen = [];
foreach ($ids as $instanzID) {
if (!IPS_InstanceExists($instanzID)) {
throw new InvalidArgumentException(
'Die Verbraucherinstanz ' . $instanzID . ' existiert nicht.'
);
}
$instanz = IPS_GetInstance($instanzID);
$modulID = strtoupper((string) ($instanz['ModuleInfo']['ModuleID'] ?? ''));
$lizenz = self::VERBRAUCHER_LIZENZEN[$modulID] ?? null;
if (!is_array($lizenz)) {
throw new InvalidArgumentException(
'Instanz ' . $instanzID . ' ist kein lizenzierbarer Enelix-Verbraucher.'
);
}
$kennung = $lizenz['Katalog'];
$mengen[$kennung] = ($mengen[$kennung] ?? 0) + 1;
$namen[$kennung] = $lizenz['Name'];
}
return [$mengen, $namen];
}
/** @return list<int> */
private function leseAktiveVerbraucherIDs(string $property): array
{
@@ -579,7 +837,7 @@ class Manager extends IPSModule implements ManagerSchnittstelle
$suchbereichID ??= $this->ReadPropertyInteger('SuchbereichID');
$werte = [];
foreach (self::VERBRAUCHER_MODUL_IDS as $modulID) {
foreach (array_keys(self::VERBRAUCHER_LIZENZEN) as $modulID) {
foreach (IPS_GetInstanceListByModuleID($modulID) as $instanzID) {
if (!$this->istImSuchbereich($instanzID, $suchbereichID)) {
continue;
@@ -743,6 +1001,12 @@ class Manager extends IPSModule implements ManagerSchnittstelle
80
);
$this->RegisterVariableString('Verbraucherstatus', 'Verbraucherstatus', '', 90);
$this->RegisterVariableString(
'NichtGeregelteGeraete',
'Nicht geregelte Geraete',
'',
95
);
$this->RegisterVariableString('Monatsgrenzen', 'Monatsgrenzen', '', 100);
$this->RegisterVariableString('Prognosestatus', 'Prognosestatus', '', 110);
$this->RegisterVariableString('SDLStatus', 'SDL-Status', '', 120);
@@ -796,23 +1060,59 @@ class Manager extends IPSModule implements ManagerSchnittstelle
$gespeicherterHash = $this->ReadAttributeString('LizenzcodeHash');
$lease = $this->leseLizenzLease();
$codePasst = $gespeicherterHash !== '' && hash_equals($gespeicherterHash, $codeHash);
$lastspitzenmodus = $this->ReadPropertyInteger('Lastspitzenmodus');
$basisGueltig = $codePasst && Lizenzpruefung::istLeaseGueltig(
$lease,
$installationID,
0
);
$gueltig = $codePasst && Lizenzpruefung::istLeaseGueltig(
$lease,
$installationID,
$this->ReadPropertyInteger('Lastspitzenmodus')
$lastspitzenmodus
);
$faellig = !$gueltig || Lizenzpruefung::erneuerungFaellig($lease);
$berechtigungenVeraltet = $basisGueltig
&& !$this->lizenzBerechtigungenPassen($lease, $lastspitzenmodus);
$routineFaellig = (!$gueltig && !$berechtigungenVeraltet)
|| Lizenzpruefung::erneuerungFaellig($lease);
$jetzt = time();
if (!$erzwingen
&& (!$faellig || $jetzt < $this->ReadAttributeInteger('LizenzNaechsterVersuch'))
if (!$erzwingen) {
if (!$routineFaellig && !$berechtigungenVeraltet) {
$this->setzeDiagnosewert('Lizenzstatus', $this->lizenzStatustext($lizenzcode));
return $gueltig;
}
if ($routineFaellig
&& $jetzt < $this->ReadAttributeInteger('LizenzNaechsterVersuch')
) {
$this->setzeDiagnosewert('Lizenzstatus', $this->lizenzStatustext($lizenzcode));
return $gueltig;
}
if ($berechtigungenVeraltet
&& $jetzt < $this->ReadAttributeInteger(
'LizenzBerechtigungenNaechsterVersuch'
)
) {
$this->setzeDiagnosewert('Lizenzstatus', $this->lizenzStatustext($lizenzcode));
$this->WriteAttributeInteger('LizenzNaechsterVersuch', $jetzt + self::LIZENZ_RETRY_SEKUNDEN);
return $gueltig;
}
}
if ($routineFaellig || $erzwingen) {
$this->WriteAttributeInteger(
'LizenzNaechsterVersuch',
$jetzt + self::LIZENZ_RETRY_SEKUNDEN
);
}
if ($berechtigungenVeraltet || $erzwingen) {
$this->WriteAttributeInteger(
'LizenzBerechtigungenNaechsterVersuch',
$jetzt + self::LIZENZ_RETRY_SEKUNDEN
);
}
try {
$neueLease = $this->rufeLizenzserverAuf($normalisiert, $installationID);
Lizenzpruefung::pruefeLease(
@@ -825,9 +1125,11 @@ class Manager extends IPSModule implements ManagerSchnittstelle
json_encode($neueLease, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES)
);
$this->WriteAttributeString('LizenzcodeHash', $codeHash);
$naechsteErneuerung = Lizenzpruefung::erneuernAb($neueLease);
$this->WriteAttributeInteger('LizenzNaechsterVersuch', $naechsteErneuerung);
$this->WriteAttributeInteger(
'LizenzNaechsterVersuch',
Lizenzpruefung::erneuernAb($neueLease)
'LizenzBerechtigungenNaechsterVersuch',
$naechsteErneuerung
);
$this->WriteAttributeString('LizenzLetzterFehler', '');
$this->setzeDiagnosewert('Lizenzstatus', $this->lizenzStatustext($lizenzcode));
@@ -860,6 +1162,108 @@ class Manager extends IPSModule implements ManagerSchnittstelle
}
}
private function peakShavingLizenziert(): bool
{
$lizenzcode = Lizenzpruefung::normalisiereCode(
$this->ReadPropertyString('Lizenzcode')
);
if (!Lizenzpruefung::istLizenzcodeGueltig($lizenzcode)) {
return false;
}
$gespeicherterHash = $this->ReadAttributeString('LizenzcodeHash');
if ($gespeicherterHash === ''
|| !hash_equals($gespeicherterHash, hash('sha256', $lizenzcode))
) {
return false;
}
$lease = $this->leseLizenzLease();
if (!Lizenzpruefung::istLeaseGueltig(
$lease,
$this->ReadAttributeString('LizenzInstallationID'),
0
)) {
return false;
}
return Lizenzpruefung::berechtigungsMenge(
$lease,
Lizenzpruefung::MANAGER_PEAK
) > 0;
}
/**
* @param array<string, mixed> $lease
*/
private function lizenzBerechtigungenPassen(array $lease, int $lastspitzenmodus): bool
{
if ($lastspitzenmodus !== 0
&& Lizenzpruefung::berechtigungsMenge(
$lease,
Lizenzpruefung::MANAGER_PEAK
) < 1
) {
return false;
}
try {
[$mengen] = $this->verbraucherLizenzbelegung(
$this->konfigurierteAktiveVerbraucherIDs()
);
Lizenzpruefung::pruefeVerbrauchermengen($lease, $mengen);
return true;
} catch (Throwable $fehler) {
return false;
}
}
/**
* @return list<array{caption: string, value: int}>
*/
private function lastspitzenOptionen(bool $lizenziert): array
{
$optionen = [[
'caption' => $lizenziert
? 'Aus (Peak-Shaving-Lizenz verfuegbar)'
: 'Aus (Peak-Shaving-Lizenz fehlt)',
'value' => 0,
]];
if ($lizenziert) {
$optionen[] = ['caption' => 'Konstant', 'value' => 1];
$optionen[] = ['caption' => 'Monatlich', 'value' => 2];
}
return $optionen;
}
private function aktualisiereLastspitzenformular(): void
{
$lizenziert = $this->peakShavingLizenziert();
$modus = $lizenziert ? $this->ReadPropertyInteger('Lastspitzenmodus') : 0;
$this->UpdateFormField(
'Lastspitzenmodus',
'options',
json_encode($this->lastspitzenOptionen($lizenziert), JSON_THROW_ON_ERROR)
);
$this->UpdateFormField('Lastspitzenmodus', 'value', $modus);
$this->UpdateFormField(
'Lastspitzengrenze',
'visible',
$lizenziert && $modus === 1
);
$this->UpdateFormField(
'MonatsgrenzenUmschalten',
'visible',
$lizenziert && $modus === 2
);
if (!$lizenziert || $modus !== 2) {
$this->SetBuffer('MonatsgrenzenSichtbar', '0');
$this->UpdateFormField('Monatsgrenzen', 'visible', false);
}
}
private function lizenzStatustext(?string $lizenzcode = null): string
{
$code = Lizenzpruefung::normalisiereCode(
@@ -886,12 +1290,16 @@ class Manager extends IPSModule implements ManagerSchnittstelle
$this->ReadAttributeString('LizenzInstallationID'),
$this->ReadPropertyInteger('Lastspitzenmodus')
);
$this->aktiveVerbraucherIDs();
$status = ($lease['development'] ?? false) === true
? 'Aktiv (Entwicklung)'
: 'Aktiv';
$status .= ' bis ' . date('d.m.Y H:i', Lizenzpruefung::offlineBis($lease));
? 'Aktiv (Entwicklung) - unbeschraenkt gueltig.'
: 'Aktiv - unbeschraenkt gueltig.';
if ($fehler !== '') {
$status .= '; Erneuerung fehlgeschlagen: ' . $fehler;
$status .= ' Online-Pruefung fehlgeschlagen; lokale Freigabe endet am '
. date('d.m.Y H:i', Lizenzpruefung::offlineBis($lease))
. ', sofern keine Verbindung hergestellt wird. '
. 'Die gekaufte Lizenz bleibt unbeschraenkt gueltig. Fehler: '
. $fehler;
}
return $status;
+50 -7
View File
@@ -6,26 +6,64 @@ Energiemanagement, Manager und steuerbare Verbraucher fuer IP-Symcon.
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.
Pufferspeicher, die Batterie, der 1-stufige Verbraucher, die Ladestation
Stand-Alone sowie das Easee Gateway mit Ladestation Gateway sind implementiert. Die weiteren
Module sind als Diskussionsentwuerfe dokumentiert.
## Geplante Module
## Module
- Manager (implementiert)
- Batterie (implementiert)
- Wassererwaermer (implementiert)
- Pufferspeicher
- Pufferspeicher (implementiert)
- Verbraucher 1-Stufig (implementiert)
- Waermepumpe (implementiert)
- Ladestation Stand-Alone (implementiert)
- Ladestation Gateway
- Easee Gateway
- Ladestation Gateway (implementiert)
- Easee Gateway (implementiert)
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.
## Betriebsartabhaengige Leistungsangebote
Der Nachrichtenvertrag `4.0` uebermittelt in beiden Richtungen verpflichtend
die aktuelle `Betriebsart`. Zulaessige Werte sind `PV` fuer Solarbetrieb und
`Peak` fuer die Lastspitzenbegrenzung. Dadurch kann jeder Verbraucher fuer
beide Betriebsarten unterschiedliche PowerSteps melden.
Ein Betriebsartwechsel wird synchronisiert:
1. Der Manager bestimmt die neue Betriebsart.
2. Er sendet sie mit `Sollleistung_W: null` an alle aktiven Verbraucher.
3. `null` ist nur eine Ankuendigung und kein Schaltbefehl.
4. Jeder Verbraucher berechnet sein zustandsabhaengiges Leistungsangebot neu
und meldet es mit derselben Betriebsart zurueck.
5. Erst wenn alle aktiven Verbraucher synchronisiert sind, verteilt der
Manager konkrete Sollleistungen.
Der Manager verwendet damit nie ein PV-Angebot fuer Peak oder umgekehrt. Eine
technische Schaltsperre, eine ausstehende Rueckmeldung oder eine Mindestzeit
kann das Angebot weiterhin auf die aktuell gehaltene Leistung begrenzen.
| Verbraucher | PV-Angebot | Peak-Angebot |
| --- | --- | --- |
| Verbraucher 1-Stufig | Normal `[0, Nennleistung]`; bei faelliger Tagesmindestlaufzeit `[Nennleistung]` | Normal `[0]`; bei faelliger Tagesmindestlaufzeit konfigurierbar `[0, Nennleistung]` oder `[Nennleistung]` |
| Ladestation Stand-Alone | Mit Solarladen `[0, ...Ladestufen]`, sonst maximale Ladeleistung | Mit Solarladen `[0]`, sonst `[0, ...Ladestufen]` |
| Ladestation Gateway | Mit Solarladen `[0, ...Ladestufen]`, sonst maximale Ladeleistung | Mit Solarladen `[0]`, sonst `[0, ...Ladestufen]` |
| Warmwassererwaermer | Unter Mindesttemperatur maximale Stufe, sonst zustandsabhaengig | Unter wirksamer Mindesttemperatur `[0, ...Leistungsstufen]`, sonst `[0]` |
| Pufferspeicher | Unter Einschaltschwelle `[0, ...Leistungsstufen]`, sonst `[0]` | Unabhaengig vom Zustand `[0]` |
| Batterie | SoC-abhaengiges Lade-/Entladeraster | SoC-, Reserve- und netzabhaengiges Angebot |
Die vollstaendigen Nachrichtenfelder und JSON-Beispiele stehen in der
[Manager-Verbraucher-Schnittstelle](docs/Schnittstelle.md). Hintergrund,
Alternativen und Folgen beschreibt
[ADR 0004](docs/adr/0004-betriebsartabhaengige-leistungsangebote.md).
## Voraussetzungen
@@ -63,11 +101,16 @@ der Uebernahme in einen Freigabebranch erfolgreich sein.
- [Zentrale Liste offener Teamentscheidungen](docs/Offene-Punkte.md)
- [Manager-Verbraucher-Schnittstelle](docs/Schnittstelle.md)
- [Batterieschnittstelle](docs/Schnittstelle-Batterie.md)
- [ADR 0004: Betriebsartabhaengige Leistungsangebote](docs/adr/0004-betriebsartabhaengige-leistungsangebote.md)
- [Manager-Modul inklusive Lizenzierung](docs/module/Manager/README.md)
- [Warmwassererwaermer-Modul](docs/module/Wassererwaermer/README.md)
- [Pufferspeicher-Modul](docs/module/Pufferspeicher/README.md)
- [Batteriemodul](docs/module/Batterie/README.md)
- [Verbraucher-1-Stufig-Modul](docs/module/Verbraucher-1-Stufig/README.md)
- [Ladestation-Stand-Alone-Modul](docs/module/Ladestation-Stand-Alone/README.md)
- [Ladestation-Gateway-Modul](docs/module/Ladestation-Gateway/README.md)
- [Easee-Gateway-Modul](docs/module/Easee-Gateway/README.md)
- [Easee-Gateway-Schnittstelle](docs/Schnittstelle-Easee-Gateway.md)
- [Migration Boiler x-Stufig](docs/migration/Boiler-x-Stufig.md)
- [Migration Verbraucher 1-Stufig](docs/migration/Verbraucher-1-Stufig.md)
- [Migration Batterie](docs/migration/Batterie.md)
+14 -13
View File
@@ -47,25 +47,13 @@ Prioritaeten, Fehlerverhalten und ein pruefbares Ergebnis.
| ID | Status | Bereich | Kurzthema |
| --- | --- | --- | --- |
| OP-001 | Entschieden | Lizenzierung | Betriebsort des Lizenzservers |
| OP-002 | Offen | Grundeinrichtung | Generator und kostenpflichtige Ersteinrichtung |
| OP-002 | Erledigt | 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
@@ -113,6 +101,19 @@ Prioritaeten, Fehlerverhalten und ein pruefbares Ergebnis.
- **Ergebnis/Verweis:** [Obere Anschluesse](Obere-Anschluesse.md) und
[Manager](module/Manager/README.md)
### OP-002: Generator und kostenpflichtige Ersteinrichtung
- **Entscheidung:** Der automatische Systemgenerator und die kostenpflichtige
Ersteinrichtung sind derselbe Vorgang.
- **Abrechnung:** Eine Bestellung aus dem Systemkonfigurator enthaelt
automatisch die noch nicht bezahlten Einrichtungskosten fuer den Manager und
die konfigurierten Verbrauchermodule. Bereits bezahlte Einrichtungsmengen
werden je Anlage angerechnet. Direkte Lizenzbestellungen enthalten keine
Einrichtungskosten.
- **Status:** Erledigt
- **Ergebnis/Verweis:** Umsetzung im Lizenzportal unter
`https://license.enelix.ch`.
## Vorlage fuer neue Punkte
```markdown
+276 -43
View File
@@ -1,63 +1,296 @@
# Ladestation Stand-Alone
> Status: Implementiert. Eigenstaendiges Verbrauchermodul mit direkter
> Geraeteanbindung; keine Beziehung zur Ladestation Gateway.
> Status: implementiert fuer IP-Symcon 8 und den Enelix-2-Vertrag `4.0`.
Das Modul bindet eine einzelne Ladestation direkt an Enelix EMS an. Es liest
den Fahrzeug- und Ladezustand ueber die Geraete-API, ermittelt die Phasenzahl
wie Enelix 1 und setzt den vom Manager gewaehlten Ladestrom. Es benoetigt keine
Gateway-Instanz und hat keine technische Beziehung zum separaten Modul
**Ladestation Gateway**.
## Funktionsumfang
- direkte Statusabfrage und Steuerung der unterstuetzten Ladestationen,
- Fahrzeug-, Lade- und Phasenerkennung nach der Enelix-1-Logik,
- Leistungsangebote fuer PV- und Peakbetrieb,
- lokale Freigabe und Umschaltung zwischen Solar- und Normalbetrieb,
- Kommunikation mit dem Enelix Manager ueber den Nachrichtenvertrag `4.0`,
- optional sichtbare Diagnosevariablen und schaltbares Debug-Logging,
- automatisierte Tests mit Fake-HTTP-Transport ohne reale Ladestation.
## Unterstuetzte Geraete
| Geraetetyp | Status | Steuerung |
| Geraetetyp | Statusabfrage | Steuerung | Erforderliche Konfiguration |
| --- | --- | --- | --- |
| go-e Charger, alte API | `GET /mqtt?payload=` | `alw` und `amp` | IP-Adresse oder Hostname |
| go-e Charger Gemini / Gemini flex | `GET /api/status` | `frc` und `amp` | IP-Adresse oder Hostname |
| smart-me Pico | Pico-Charging-API | Load-Management-Current-API | Geraete-ID, Seriennummer, Benutzername und Passwort |
Bei go-e wird die lokale HTTP-API verwendet. Die Geraeteadresse wird ohne
`http://`, Pfad oder Parameter eingetragen. Die Pico-Anbindung verwendet HTTPS
und HTTP Basic Auth gegen `api.smart-me.com`. Zugangsdaten werden weder als
Variable noch im Debug-Protokoll oder im Diagnosefeld
`LetzterGeraetebefehl` ausgegeben.
## Fahrzeug- und Phasenerkennung
Die Auswertung folgt bewusst dem Verhalten der bisherigen Enelix-1-Ladestation:
1. Bei go-e gilt das Fahrzeug als verbunden, wenn `car != 1` ist. Bei Pico
wird entsprechend `State != 1` ausgewertet.
2. Die gemessene Leistung wird bei der alten go-e-API mit Faktor 10, bei
Gemini direkt in Watt und bei Pico von kW in Watt umgerechnet.
3. Bei einer Leistung ueber 7500 W wird dreiphasiges Laden erkannt. Darunter
wird bei verbundenem Fahrzeug einphasiges Laden angenommen.
4. Der Ladestrom wird aus der Leistung mit `230 V` einphasig beziehungsweise
`1.71 * 400 V` dreiphasig berechnet.
5. War das Fahrzeug bereits im vorherigen Zyklus verbunden und liegt der
ermittelte Maximalstrom aus Ladestrom plus `2.5 A` unter `6 A`, wird
`FahrzeugGeladen=true` gesetzt.
Der normalisierte `Fahrzeugstatus` verwendet folgende Werte:
| Wert | Bedeutung |
| ---: | --- |
| `0` | unbekannt oder noch nicht ermittelt |
| `1` | kein Fahrzeug verbunden |
| `2` | Fahrzeug verbunden und bereit |
| `3` | Fahrzeug laedt |
| `4` | Fahrzeug geladen |
## Leistungsregelung
Ein Leistungsangebot groesser als `0 W` wird nur erzeugt, wenn das Modul
aktiviert, die Ladefreigabe gesetzt, ein Fahrzeug verbunden und das Fahrzeug
noch nicht als geladen erkannt ist. Andernfalls meldet das Modul `[0]` und
stoppt die Ladestation.
| Betriebsart | Solarladen | Leistungsangebot |
| --- | --- | --- |
| 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 |
| PV | ein | `0 W` und alle Ladestufen von 6 A bis zum konfigurierten Maximum |
| PV | aus | ausschliesslich die maximale Ladeleistung |
| Peak | ein | ausschliesslich `[0]`; die Ladestation wird gestoppt |
| Peak | aus | `0 W` und alle Ladestufen von 6 A bis zum konfigurierten Maximum |
Die Routen, Leistungsfaktoren und Erkennung wurden gezielt aus Enelix 1
uebernommen. Zugangsdaten werden weder als Variable noch im Diagnoseprotokoll
ausgegeben.
Die festen ein- und dreiphasigen Leistungsstufen sowie deren Rundung wurden aus
Enelix 1 uebernommen. Ohne gueltige Manager-Vorgabe faehrt das Modul bei
aktivem Solarladen mit `0 A`; bei ausgeschaltetem Solarladen verwendet es die
maximale angebotene Leistung.
## Verhalten
## Bedienung
- `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.
| Ident | Wirkung |
| --- | --- |
| `Aktiv` | Gemeinsame lokale EMS-Freigabe. `false` setzt das Angebot auf `[0]` und stoppt die Ladestation. |
| `Ladefreigabe` | Lokale Freigabe fuer das Laden. Diese Variable ist nur mit `EinstellungenInVisu=true` sichtbar. |
| `Solarladen` | Schaltet das oben beschriebene PV-/Peak-Verhalten um. Diese Variable ist nur mit `EinstellungenInVisu=true` sichtbar. |
Die Properties `Ladefreigabe` und `Solarladen` definieren die Startwerte. Eine
Aenderung der jeweiligen Property wird beim Anwenden der Konfiguration in den
lokalen Zustand uebernommen. Eine Bedienung der Variablen verwirft eine noch
gueltige Manager-Vorgabe und berechnet das Leistungsangebot sofort neu.
## Konfiguration
### Gemeinsame EMS-Einstellungen
| Property | Typ | Standard | Beschreibung |
| --- | --- | ---: | --- |
| `PrioritaetPV` | Integer | `0` | Prioritaet des Verbrauchers im PV-Betrieb. |
| `PrioritaetPeak` | Integer | `0` | Prioritaet des Verbrauchers im Peakbetrieb. |
| `Meldeintervall` | Integer | `10 s` | Intervall fuer die periodische Vollmeldung an zugeordnete Manager. |
| `VorgabeTimeout` | Integer | `120 s` | Gueltigkeitsdauer einer Manager-Vorgabe ohne Erneuerung. |
| `EinstellungenInVisu` | Boolean | `false` | Blendet `Ladefreigabe` und `Solarladen` als bedienbare Variablen ein. |
| `LoggingEin` | Boolean | `false` | Aktiviert das laufende Debug-Protokoll des Moduls. |
### Ladestation und Diagnose
| Property | Typ | Standard | Beschreibung |
| --- | --- | ---: | --- |
| `Geraetetyp` | Integer | `0` | `1` go-e alt, `2` go-e Gemini, `3` smart-me Pico; `0` ist nicht konfiguriert. |
| `Geraeteadresse` | String | leer | IP-Adresse oder Hostname fuer beide go-e-Varianten. |
| `GeraeteID` | String | leer | Geraete-ID fuer die Pico-Statusabfrage. |
| `Seriennummer` | String | leer | Seriennummer fuer die Pico-Stromvorgabe. |
| `Benutzername` | String | leer | Benutzername fuer die Pico-API. |
| `Passwort` | String | leer | Passwort fuer die Pico-API; im Formular als Passwortfeld dargestellt. |
| `MaximalerLadestrom` | Integer | `16 A` | Obergrenze der angebotenen Ladestufen; zulaessig sind 6 bis 32 A. |
| `Abfrageintervall` | Integer | `5 s` | Intervall der Geraetestatusabfrage. |
| `Ladefreigabe` | Boolean | `false` | Startwert der lokalen Ladefreigabe. |
| `Solarladen` | Boolean | `true` | Startwert der lokalen Solarlogik. |
| `DiagnosevariablenAnzeigen` | Boolean | `false` | Blendet gemeinsame und ladestationsspezifische Diagnosevariablen ein. |
Die internen Properties `Testmodus` und `Testantwort` gehoeren ausschliesslich
zum automatisierten Symcon-Funktionstest und erscheinen nicht im
Konfigurationsformular.
## Variablen
Immer sichtbar:
| 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. |
| `Aktiv` | Boolean / bedienbar | Lokale EMS-Freigabe; der Initialwert ist `false`. |
| `FahrzeugVerbunden` | Boolean / Anzeige | Zeigt, ob die API ein verbundenes Fahrzeug meldet. |
| `FahrzeugGeladen` | Boolean / Anzeige | Ergebnis der kompatiblen Enelix-1-Voll-Erkennung. |
| `Fahrzeugstatus` | Integer / Anzeige | Normalisierter Status von `0` bis `4`. |
| `Ladestrom` | Float / Anzeige | Aus der gemessenen Leistung ermittelter Ladestrom in A. |
| `Phasenzahl` | Integer / Anzeige | `0` unbekannt, `1` einphasig oder `3` dreiphasig. |
## Properties
Nur mit `EinstellungenInVisu=true` sichtbar und bedienbar:
Neben den gemeinsamen Verbraucher-Properties werden Geraetetyp, Adresse,
Pico-ID, Seriennummer, Zugangsdaten, maximaler Ladestrom, Abfrageintervall
sowie die Startwerte fuer Ladefreigabe und Solarladen konfiguriert.
- `Ladefreigabe`
- `Solarladen`
Die gemeinsamen Verbraucher- und Diagnosevariablen entsprechen der
EMS-Schnittstelle Version 4.0. `LoggingEin` steuert ausschliesslich das
laufende Debug-Protokoll.
Nur mit `DiagnosevariablenAnzeigen=true` sichtbar:
| Ident | Beschreibung |
| --- | --- |
| `Istleistung` | Von der Ladestation gemessene Leistung in W. |
| `Leistungsquelle` | Immer `2` fuer eine gemessene Leistung. |
| `Sollleistung` | Letzte angenommene Manager-Vorgabe in W. |
| `SollwertGueltig` | Zeigt, ob die Vorgabe noch gueltig und im aktuellen Angebot enthalten ist. |
| `Verfuegbar` | Zeigt, ob die Ladestation grundsaetzlich Leistung aufnehmen kann. |
| `AenderungMoeglich` | Zeigt, ob das aktuelle Angebot mehr als eine Leistungsstufe enthaelt. |
| `Stoerung` | Sammelstatus fuer Konfigurations- und Kommunikationsfehler. |
| `Stoertext` | Letzte verstaendliche Fehlerbeschreibung. |
| `LetzterGeraetebefehl` | Letzte HTTP-Methode und URL ohne Zugangsdaten. |
| `LeistungsangebotDiagnose` | Aktuelles Leistungsangebot als JSON-Liste in W. |
Die Regellogik speichert ihren Zustand unabhaengig von der Sichtbarkeit der
Diagnosevariablen. Das Ein- oder Ausblenden veraendert daher nicht das
Regelverhalten.
## Managerkommunikation
Das Modul implementiert
`VerbraucherSchnittstelle::ManagerdatenEmpfangen()` und verwendet
[ausschliesslich den Nachrichtenvertrag `4.0`](../../Schnittstelle.md). Eine
separate Manager-ID wird nicht konfiguriert. Das Modul akzeptiert Vorgaben nur
von einem Manager, in dessen manueller oder automatischer
Verbraucherzuordnung die Ladestation aktiv eingetragen ist.
An den Manager werden neben den gemeinsamen Feldern diese Zustaende gemeldet:
- `Sollleistung_W`
- `FahrzeugVerbunden`
- `FahrzeugGeladen`
- `Fahrzeugstatus`
- `Phasenzahl`
- `Ladestrom_A`
- `Ladefreigabe`
- `Solarladen`
- `Ladefehler` mit Stoertext
Eine Sollleistung wird nur angenommen, wenn sie im zuletzt berechneten
Leistungsangebot enthalten ist. Nach `VorgabeTimeout` ohne Erneuerung wird sie
ungueltig. Statusaenderungen werden kurz verzoegert und zusaetzlich alle
`Meldeintervall` Sekunden an alle zugeordneten Manager gemeldet.
## Instanzstatus und Fehlerbehandlung
| Status | Bedeutung |
| ---: | --- |
| `102` | Konfiguration und letzte Geraetekommunikation sind gueltig. |
| `201` | Konfiguration ungueltig, beispielsweise fehlende Adresse oder Pico-Zugangsdaten. |
| `202` | Geraetekommunikation oder Antwortauswertung fehlgeschlagen. |
Bei einem Fehler meldet das Modul `Verfuegbar=false`,
`AenderungMoeglich=false` und setzt `Stoerung` sowie `Stoertext`. HTTP-Anfragen
verwenden 5 Sekunden Verbindungs- und 10 Sekunden Gesamt-Timeout. Antworten ab
HTTP-Status 400 sowie unvollstaendige oder ungueltige JSON-Antworten gelten als
Kommunikationsfehler.
## 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 **Ladestation Stand-Alone** suchen und
eine Instanz anlegen.
3. Den Geraetetyp auswaehlen und die dazugehoerigen Verbindungsdaten eintragen:
bei go-e nur IP-Adresse oder Hostname, bei Pico Geraete-ID, Seriennummer,
Benutzername und Passwort.
4. Den maximal zulaessigen Ladestrom der Installation zwischen 6 und 32 A
einstellen. Diese Grenze ersetzt keine elektrische Absicherung.
5. `Abfrageintervall`, `Meldeintervall`, `VorgabeTimeout` sowie die beiden
Prioritaeten festlegen.
6. Die gewuenschten Startwerte fuer `Ladefreigabe` und `Solarladen` setzen.
7. Fuer die Erstinbetriebnahme `EinstellungenInVisu`,
`DiagnosevariablenAnzeigen` und bei Bedarf `LoggingEin` aktivieren.
8. Die Instanz im Manager manuell aktiv zuordnen oder bei automatischer Suche
in der gefundenen Liste aktivieren.
9. Zuerst ohne Fahrzeug kontrollieren, ob die Statusabfrage fehlerfrei ist.
Danach unter Aufsicht ein Fahrzeug verbinden und `Aktiv` einschalten.
Fuer go-e muss IP-Symcon das Geraet im lokalen Netz per HTTP erreichen koennen.
Fuer Pico ist ausgehender HTTPS-Zugriff auf `api.smart-me.com` erforderlich.
Passwoerter gehoeren ausschliesslich in das dafuer vorgesehene Passwortfeld und
niemals in Repository-Dateien, Skripte oder Screenshots.
## Abnahmecheckliste
- Die Instanz erreicht Status `102`, und `Stoerung` bleibt `false`.
- Ohne Fahrzeug sind `FahrzeugVerbunden=false`, `Phasenzahl=0` und das
Leistungsangebot `[0]`.
- Nach dem Anstecken wird das Fahrzeug erkannt und die gemessene Leistung
plausibel in `Istleistung` und `Ladestrom` abgebildet.
- Bei einer Ladeleistung bis 7500 W wird eine Phase, darueber werden drei
Phasen angezeigt.
- `Aktiv=false` oder `Ladefreigabe=false` stoppt die Ladestation.
- PV mit `Solarladen=true` bietet `0 W` und die Ladestufen an.
- PV mit `Solarladen=false` bietet nur die maximale Leistung an.
- Peak mit `Solarladen=true` stoppt die Ladestation und bietet nur `[0]` an.
- Peak mit `Solarladen=false` bietet `0 W` und die Ladestufen an.
- Eine Manager-Vorgabe ausserhalb des gemeldeten Angebots wird abgewiesen.
- Nach Ablauf des Vorgabe-Timeouts gilt wieder das lokale Ersatzverhalten.
- Im Debug-Protokoll und in `LetzterGeraetebefehl` erscheinen keine
Zugangsdaten.
## Fehlersuche
- Status `201`: Geraetetyp und Pflichtfelder kontrollieren. Bei go-e darf die
Adresse kein Protokoll, keinen Pfad und keine Parameter enthalten. Bei Pico
muessen alle vier Zugangsfelder befuellt sein.
- Status `202`: Erreichbarkeit, DNS, lokale Firewall und API-Antwort pruefen.
`Stoertext` enthaelt den konkreten Kommunikations- oder JSON-Fehler.
- Fahrzeug wird nicht erkannt: Rohstatus der Geraete-API kontrollieren. Der
Adapter erwartet bei go-e `car` und `nrg[11]`, bei Pico `State` und
`ActiveChargingPower`.
- Falsche Phasenzahl bei sehr kleiner oder stehender Ladung: Die kompatible
Enelix-1-Erkennung basiert auf der momentanen Leistung. Fuer eine sichere
Dreiphasenerkennung muss waehrend der Erkennung mehr als 7500 W anliegen.
- Manager-Vorgabe wird abgewiesen: aktive Zuordnung im Manager sowie
`LeistungsangebotDiagnose` und die Betriebsart kontrollieren.
## 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.
Die Unit- und Adaptertests verwenden einen injizierten Fake-HTTP-Transport.
Damit werden fuer go-e alt, go-e Gemini und smart-me Pico Statusantworten,
URL, HTTP-Methode, Authentisierung und Steueraufrufe ohne reale Hardware
geprueft.
Der Symcon-Funktionstest verwendet den internen `Testmodus` mit simulierten
API-Antworten. Er prueft alle drei Geraetevarianten, Fahrzeug- und
Phasenerkennung, die Leistungsangebote in PV und Peak sowie die Umschaltung
ueber den `Solarladen`-Button.
PHP-Syntax, Struktur- und Unit-Tests des gesamten Repositorys:
```bash
composer check
```
Funktionstest nur fuer die Ladestation gegen IP-Symcon 8:
```bash
tests/Symcon/bin/run-symcon-tests.sh single LadestationStandAlone
```
Vollstaendiger Symcon-Modultestlauf:
```bash
tests/Symcon/bin/run-symcon-tests.sh all
```
Aufbau, Testvertrag und Ergebnisdateien sind unter
[`docs/testing/README.md`](../../testing/README.md) beschrieben.
+4 -3
View File
@@ -1,8 +1,9 @@
# EMS-Module und Modulentwürfe
> Manager, Warmwassererwaermer, Batterie, Verbraucher 1-Stufig, Waermepumpe,
> Ladestation Stand-Alone, Easee Gateway und Ladestation Gateway sind als
> installierbare IP-Symcon-Module umgesetzt. Die weiteren Ordner enthalten Besprechungsgrundlagen.
> Manager, Warmwassererwaermer, Pufferspeicher, Batterie, Verbraucher 1-Stufig,
> Waermepumpe, Ladestation Stand-Alone, Easee Gateway und Ladestation Gateway
> 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
+37 -17
View File
@@ -2,13 +2,16 @@
Die Demo erzeugt idempotent eine vollständige, spielbare EMS-Anlage in IP-Symcon 8.x:
- Enelix Manager mit manueller Zuordnung,
- Enelix Manager mit manueller Zuordnung von fünf steuerbaren Teilnehmern,
- 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,
- einen zweistufigen Pufferspeicher (1,8/3,6 kW) mit Heizkurve,
- einen bidirektionalen Batteriespeicher (10 kWh, +/-3,5 kW),
- simulierte PV-Erzeugung, variable Bewölkung, Tageslastgang, Zusatzlast,
Außentemperatur, Wärmeverluste, Netzleistung und Speicherzustände,
- native Symcon-Instanz `Energy Distribution` für acht Energieflussknoten,
- eigene Kachelansichten mit Laufzeitparametern für Boiler, Puffer und Batterie,
- kombinierten Verlauf für PV-Leistung, Hausverbrauch, EMS-Leistung und Netzleistung,
- kombinierten Verlauf der kumulierten PV-, Haus-, Bezugs- und Einspeiseenergie,
- Archivierung der Leistungs-, Energie- und Temperaturwerte.
@@ -27,26 +30,43 @@ 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.
Der Bootstrap hinterlegt absichtlich keinen Lizenzcode und überschreibt eine
bereits konfigurierte Manager-Lizenz nicht. Bei einer neuen Anlage wird die
Struktur vollständig erstellt; der Manager bleibt bis zur regulären
Lizenzaktivierung im Konfigurationsformular auf Status 203. Solange der Manager
nicht freigegeben ist, bleiben seine Zuordnung und die Verbraucher deaktiviert,
damit keine Lizenzfehler protokolliert werden. Nach der Aktivierung den
Bootstrap erneut ausführen; er ordnet dann alle fünf Teilnehmer zu und startet
die Regelung.
## 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.
dynamische Profile, PV-Spitzenleistung, mittlere Bewölkung, Haus-Grundlast und
eine ungeregelte Zusatzlast verändern. Bei aktivierten dynamischen Profilen
entstehen reproduzierbare Morgen- und Abendspitzen, ziehende Bewölkung und ein
Tagesgang der Außentemperatur. Der Manager verteilt den verfügbaren
PV-Überschuss nach Priorität auf beide Boiler, Entfeuchter, Pufferspeicher und
Batterie.
`Ü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.
`Übersicht` zeigt die native Symcon-Energieverteilung. Die Kacheln `Boiler 1`,
`Boiler 2`, `Pufferspeicher` und `Batteriespeicher` öffnen direkt die jeweiligen
Bedien-, Status- und Diagnosevariablen, ohne zuerst das
Instanz-Konfigurationsformular zu zeigen. Positive Batterieleistung bedeutet
Laden, negative Leistung Entladen. Unter `Leistung und Energie` liegen die
beiden kombinierten Zeitdiagramme und die Energiezähler. Die Energiesummen,
Temperaturmodelle und der Ladezustand 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.
- Die fünf Verbraucher müssen Status 102 besitzen; der Manager nach Lizenzaktivierung ebenfalls (vorher erwarteter Status 203).
- Der Manager muss fünf Teilnehmer aus vier unterschiedlichen Modultypen melden.
- Bei ausreichendem PV-Überschuss müssen thermische Verbraucher schalten und die Batterie laden.
- Puffertemperatur, Außentemperatur und Batterie-Ladezustand müssen sich über den Tageslauf verändern.
- Netzleistung, PV-Leistung, Hausverbrauch, EMS-Leistung, Einzelverbräuche, Speicherleistung, Energiesummen und Temperaturen werden archiviert.
- Die native Energieverteilung muss acht Knoten enthalten.
- Beide Diagrammkacheln müssen mindestens drei beschriftete Zeitreihen anzeigen.
- Ein erneuter Installationslauf muss dieselbe Root-ID zurückgeben.
+336 -75
View File
@@ -6,6 +6,8 @@ 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_BUFFER_MODULE = '{C92D5EEF-9632-47A5-9659-4B02BF40FBE9}';
const ENELIX_DEMO_BATTERY_MODULE = '{437FB683-517F-4FEC-8CCB-FE6B0A62B69E}';
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}';
@@ -28,15 +30,17 @@ function enelixDemoInstall(): array
enelixDemoEnsureProfiles();
$legacyFlow = enelixDemoEnsureVariable($dashboard, 'Energiefluss', 'Energiefluss (Legacy)', 3, '~HTMLBox', '', 90);
$legacyFlow = enelixDemoEnsureVariable($dashboard, 'Energiefluss', 'Energiefluss (Legacy)', 3, '~HTMLBox', '', 900);
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);
$loadDisplay = enelixDemoEnsureVariable($dashboard, 'Verbraucherleistung', 'EMS-Leistung inkl. Speicher', 2, 'ENELIX.Demo.Power', 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);
$bufferPower = enelixDemoEnsureVariable($dashboard, 'PufferLeistung', 'Pufferspeicher Leistung', 2, 'ENELIX.Demo.PowerPositive', 0.0, 90);
$batteryPower = enelixDemoEnsureVariable($dashboard, 'BatterieLeistung', 'Batterieleistung', 2, 'ENELIX.Demo.Power', 0.0, 100);
$powerChart = enelixDemoEnsureVariable($history, 'Leistungsdiagramm', 'Leistungsverlauf (Legacy)', 3, '~HTMLBox', '', 90);
$energyChart = enelixDemoEnsureVariable($history, 'Energiediagramm', 'Kumulierte Energien (Legacy)', 3, '~HTMLBox', '', 100);
@@ -50,9 +54,11 @@ function enelixDemoInstall(): array
$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);
$dynamic = enelixDemoEnsureVariable($simulation, 'DynamischeProfile', 'Dynamische Tagesprofile', 0, '~Switch', true, 40);
$peak = enelixDemoEnsureVariable($simulation, 'PVSpitze', 'PV-Spitzenleistung', 2, 'ENELIX.Demo.PowerPositive', 9000.0, 50);
$cloud = enelixDemoEnsureVariable($simulation, 'Bewoelkung', 'Mittlere Bewölkung', 1, 'ENELIX.Demo.Percent', 20, 60);
$base = enelixDemoEnsureVariable($simulation, 'Grundlast', 'Mittlere Haus-Grundlast', 2, 'ENELIX.Demo.PowerPositive', 900.0, 70);
$extraLoad = enelixDemoEnsureVariable($simulation, 'Zusatzlast', 'Ungeregelte Zusatzlast', 2, 'ENELIX.Demo.PowerPositive', 0.0, 80);
$actorScript = enelixDemoEnsureScript(
$technical,
@@ -61,6 +67,13 @@ function enelixDemoInstall(): array
"<?php\nSetValue((int) \$_IPS['VARIABLE'], (bool) \$_IPS['VALUE']);\n",
10
);
$registerScript = enelixDemoEnsureScript(
$technical,
'RegisterAktion',
'Simulierte Register',
"<?php\nSetValue((int) \$_IPS['VARIABLE'], \$_IPS['VALUE']);\n",
15
);
$tickScript = enelixDemoEnsureScript(
$technical,
'SimulationTick',
@@ -76,7 +89,7 @@ function enelixDemoInstall(): array
30
);
foreach ([$auto, $hour, $speed, $peak, $cloud, $base] as $variableId) {
foreach ([$auto, $hour, $speed, $dynamic, $peak, $cloud, $base, $extraLoad] as $variableId) {
IPS_SetVariableCustomAction($variableId, $controlScript);
}
@@ -84,36 +97,57 @@ function enelixDemoInstall(): array
$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);
$bufferTemp = enelixDemoEnsureVariable($technical, 'PufferTemperatur', 'Puffertemperatur', 2, '~Temperature', 36.0, 120);
$outsideTemp = enelixDemoEnsureVariable($technical, 'AussenTemperatur', 'Aussentemperatur', 2, '~Temperature', 6.0, 130);
$boiler11 = enelixDemoEnsureVariable($technical, 'Boiler1Stufe1', 'Boiler 1 Stufe 1', 0, '~Switch', false, 200);
$boiler12 = enelixDemoEnsureVariable($technical, 'Boiler1Stufe2', 'Boiler 1 Stufe 2', 0, '~Switch', false, 210);
$boiler21 = enelixDemoEnsureVariable($technical, 'Boiler2Stufe1', 'Boiler 2 Stufe 1', 0, '~Switch', false, 220);
$boiler22 = enelixDemoEnsureVariable($technical, 'Boiler2Stufe2', 'Boiler 2 Stufe 2', 0, '~Switch', false, 230);
$dryContact = enelixDemoEnsureVariable($technical, 'EntfeuchterKontakt', 'Entfeuchter Kontakt', 0, '~Switch', false, 240);
$buffer1 = enelixDemoEnsureVariable($technical, 'PufferStufe1', 'Puffer Stufe 1', 0, '~Switch', false, 250);
$buffer2 = enelixDemoEnsureVariable($technical, 'PufferStufe2', 'Puffer Stufe 2', 0, '~Switch', false, 260);
$batteryMaxCharge = enelixDemoEnsureVariable($technical, 'BatterieMaxLaden', 'Batterie max. laden', 2, 'ENELIX.Demo.PowerPositive', 3500.0, 300);
$batteryMaxDischarge = enelixDemoEnsureVariable($technical, 'BatterieMaxEntladen', 'Batterie max. entladen', 2, 'ENELIX.Demo.PowerPositive', 3500.0, 310);
$batterySoc = enelixDemoEnsureVariable($technical, 'BatterieLadezustand', 'Batterie Ladezustand', 2, 'ENELIX.Demo.PercentFloat', 52.0, 320);
$batteryActual = enelixDemoEnsureVariable($technical, 'BatterieIstleistung', 'Batterie Istleistung', 2, 'ENELIX.Demo.Power', 0.0, 330);
$batteryManagement = enelixDemoEnsureVariable($technical, 'BatterieManagement', 'Batterie Managementregister', 1, '', 0, 340);
$batteryMode = enelixDemoEnsureVariable($technical, 'BatterieModus', 'Batterie Modusregister', 1, '', 0, 350);
$batteryRegisterPower = enelixDemoEnsureVariable($technical, 'BatterieRegisterLeistung', 'Batterie Leistungsregister', 2, 'ENELIX.Demo.PowerPositive', 0.0, 360);
$chartUpdated = enelixDemoEnsureVariable($technical, 'LetztesDiagrammUpdate', 'Letztes Diagramm-Update', 1, '', 0, 390);
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);
$flowPv = enelixDemoEnsureVariable($technical, 'EnergieflussPVkW', 'PV-Leistung', 2, '~Power', 0.0, 500);
$flowGrid = enelixDemoEnsureVariable($technical, 'EnergieflussNetzkW', 'Netzleistung', 2, '~Power', 0.0, 510);
$flowBase = enelixDemoEnsureVariable($technical, 'EnergieflussGrundlastkW', 'Haus-Grundlast', 2, '~Power', 0.0, 520);
$flowBoiler1 = enelixDemoEnsureVariable($technical, 'EnergieflussBoiler1kW', 'Boiler 1', 2, '~Power', 0.0, 530);
$flowBoiler2 = enelixDemoEnsureVariable($technical, 'EnergieflussBoiler2kW', 'Boiler 2', 2, '~Power', 0.0, 540);
$flowDryer = enelixDemoEnsureVariable($technical, 'EnergieflussEntfeuchterkW', 'Entfeuchter', 2, '~Power', 0.0, 550);
$flowBuffer = enelixDemoEnsureVariable($technical, 'EnergieflussPufferkW', 'Pufferspeicher', 2, '~Power', 0.0, 560);
$flowBattery = enelixDemoEnsureVariable($technical, 'EnergieflussBatteriekW', 'Batteriespeicher', 2, '~Power', 0.0, 570);
$flowHouse = enelixDemoEnsureVariable($technical, 'DiagrammHausverbrauchkW', 'Hausverbrauch gesamt', 2, '~Power', 0.0, 580);
$flowLoads = enelixDemoEnsureVariable($technical, 'DiagrammVerbraucherkW', 'EMS-Leistung inkl. Speicher', 2, '~Power', 0.0, 590);
foreach ([
$flowPv, $flowGrid, $flowBase, $flowBoiler1,
$flowBoiler2, $flowDryer, $flowHouse, $flowLoads,
$flowPv, $flowGrid, $flowBase, $flowBoiler1, $flowBoiler2,
$flowDryer, $flowBuffer, $flowBattery, $flowHouse, $flowLoads,
] as $flowVariableId) {
IPS_SetHidden($flowVariableId, true);
}
foreach ([$boiler11, $boiler12, $boiler21, $boiler22, $dryContact] as $actorId) {
foreach ([$boiler11, $boiler12, $boiler21, $boiler22, $dryContact, $buffer1, $buffer2] as $actorId) {
IPS_SetVariableCustomAction($actorId, $actorScript);
}
foreach ([$batteryManagement, $batteryMode, $batteryRegisterPower] as $registerId) {
IPS_SetVariableCustomAction($registerId, $registerScript);
}
foreach ([$temp1, $temp2, $bufferTemp, $outsideTemp, $batteryMaxCharge, $batteryMaxDischarge, $batterySoc, $batteryActual] as $sensorId) {
SetValue($sensorId, GetValue($sensorId));
}
$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);
$buffer = enelixDemoEnsureInstance($ems, 'Pufferspeicher', 'Pufferspeicher · Heizkurve', ENELIX_DEMO_BUFFER_MODULE, 50);
$battery = enelixDemoEnsureInstance($ems, 'Batterie', 'Batteriespeicher · 10 kWh', ENELIX_DEMO_BATTERY_MODULE, 60);
enelixDemoConfigure($boiler1, [
'Boilerfuehler_PT1' => $temp1,
@@ -157,6 +191,52 @@ function enelixDemoInstall(): array
'Mindestausschaltdauer' => 2,
'Mindestlaufzeit' => 0,
'PrioritaetPV' => 2,
'PrioritaetPeak' => 3,
'Meldeintervall' => 5,
'VorgabeTimeout' => 30,
'EinstellungenInVisu' => true,
'DiagnosevariablenAnzeigen' => true,
]);
enelixDemoConfigure($buffer, [
'LeistungsStufen' => json_encode([
['Stufe' => 1, 'Leistung' => 1800, 'Schaltkontakt_Stufe' => $buffer1],
['Stufe' => 2, 'Leistung' => 3600, 'Schaltkontakt_Stufe' => $buffer2],
], JSON_THROW_ON_ERROR),
'PufferfuehlerVariableID' => $bufferTemp,
'AussentemperaturVariableID' => $outsideTemp,
'FusspunktVorlauftemperatur' => 34.0,
'HeizkurvenSteigung' => 0.8,
'HeizkurveMinimaltemperatur' => 28.0,
'HeizkurveMaximaltemperatur' => 55.0,
'Hysterese' => 4.0,
'MindesttemperaturModus' => 0,
'LastwechselSperrzeit' => 2,
'TemperaturMaxAlter' => 30,
'PuffertemperaturGlaetten' => false,
'PrioritaetPV' => 3,
'PrioritaetPeak' => 4,
'Meldeintervall' => 5,
'VorgabeTimeout' => 30,
'EinstellungenInVisu' => true,
'DiagnosevariablenAnzeigen' => true,
]);
enelixDemoConfigure($battery, [
'Batterietyp' => 2,
'Batteriemanagement' => 2,
'MaxLadeleistungVariableID' => $batteryMaxCharge,
'MaxEntladeleistungVariableID' => $batteryMaxDischarge,
'LadezustandVariableID' => $batterySoc,
'NetzleistungVariableID' => $grid,
'IstleistungVariableID' => $batteryActual,
'ManagementRegisterVariableID' => $batteryManagement,
'ModusRegisterVariableID' => $batteryMode,
'LeistungsRegisterVariableID' => $batteryRegisterPower,
'ReserveLadezustand' => 20.0,
'MindestLadezustand' => 10.0,
'LadezustandHysterese' => 3.0,
'MesswertMaxAlter' => 30,
'Aenderungssperre' => 0,
'PrioritaetPV' => 4,
'PrioritaetPeak' => 0,
'Meldeintervall' => 5,
'VorgabeTimeout' => 30,
@@ -178,6 +258,8 @@ function enelixDemoInstall(): array
enelixDemoEnergyNode($flowBoiler1, 'Boiler 1', 'consumer', $temp1),
enelixDemoEnergyNode($flowBoiler2, 'Boiler 2', 'consumer', $temp2),
enelixDemoEnergyNode($flowDryer, 'Entfeuchter', 'consumer'),
enelixDemoEnergyNode($flowBuffer, 'Pufferspeicher', 'consumer', $bufferTemp),
enelixDemoEnergyNode($flowBattery, 'Batteriespeicher', 'battery', $batterySoc),
]);
IPS_SetHidden($energyDistribution, false);
$oldEnergyDistribution = enelixDemoFind($dashboard, 'Energieverteilung');
@@ -188,34 +270,47 @@ function enelixDemoInstall(): array
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]
[$boiler1, $boiler2, $dryer, $buffer, $battery]
);
enelixDemoConfigure($manager, [
$managerProperties = [
'NetzleistungVariableID' => $grid,
'Netzleistungsfaktor' => 1.0,
'MesswertMaxAlter' => 10,
'AutomatischeSuche' => false,
'SuchbereichID' => $root,
'VerbraucherZuordnung' => json_encode($assignment, JSON_THROW_ON_ERROR),
'VerbraucherZuordnung' => '[]',
'KeepAlive' => 5,
'VerbraucherTimeout' => 15,
'SollwertSolarladen' => 0.0,
'Umschaltdifferenz' => 0.0,
'DiagnosevariablenAnzeigen' => true,
'LoggingEin' => false,
]);
];
enelixDemoConfigure($manager, $managerProperties, [102, 203]);
$managerReady = (int) IPS_GetInstance($manager)['InstanceStatus'] === 102;
if ($managerReady) {
$managerProperties['VerbraucherZuordnung'] = json_encode($assignment, JSON_THROW_ON_ERROR);
enelixDemoConfigure($manager, $managerProperties, [102, 203]);
$managerReady = (int) IPS_GetInstance($manager)['InstanceStatus'] === 102;
}
if (!$managerReady && IPS_GetProperty($manager, 'VerbraucherZuordnung') !== '[]') {
$managerProperties['VerbraucherZuordnung'] = '[]';
enelixDemoConfigure($manager, $managerProperties, [203]);
}
foreach ([$boiler1, $boiler2, $dryer] as $consumerId) {
$consumerIds = [$boiler1, $boiler2, $dryer, $buffer, $battery];
foreach ($consumerIds as $consumerId) {
IPS_RequestAction($consumerId, 'Aktiv', $managerReady);
if ($managerReady) {
IPS_RequestAction($consumerId, 'Melden', true);
}
IPS_RequestAction($manager, 'Aktiv', true);
}
IPS_RequestAction($manager, 'Aktiv', $managerReady);
$boiler1List = enelixDemoEnsureInstance(
$dashboard,
@@ -231,14 +326,32 @@ function enelixDemoInstall(): array
ENELIX_DEMO_DUMMY_MODULE,
120
);
enelixDemoConfigure($boiler1List, []);
enelixDemoConfigure($boiler2List, []);
$bufferList = enelixDemoEnsureInstance(
$dashboard,
'PufferListe',
'Pufferspeicher',
ENELIX_DEMO_DUMMY_MODULE,
130
);
$batteryList = enelixDemoEnsureInstance(
$dashboard,
'BatterieListe',
'Batteriespeicher',
ENELIX_DEMO_DUMMY_MODULE,
140
);
foreach ([$boiler1List, $boiler2List, $bufferList, $batteryList] as $listId) {
enelixDemoConfigure($listId, []);
}
enelixDemoLinkInstanceVariables($boiler1List, $boiler1, $temp1);
enelixDemoLinkInstanceVariables($boiler2List, $boiler2, $temp2);
enelixDemoLinkInstanceVariables($bufferList, $buffer, $bufferTemp, 'Puffertemperatur', 'SensorPuffertemperatur');
enelixDemoLinkInstanceVariables($batteryList, $battery, $batterySoc, 'Ladezustand', 'SensorLadezustand');
IPS_SetHidden($boiler1View, true);
IPS_SetHidden($boiler2View, true);
IPS_SetHidden($boiler1, true);
IPS_SetHidden($boiler2, true);
foreach ([$boiler1, $boiler2, $buffer, $battery] as $hiddenInstanceId) {
IPS_SetHidden($hiddenInstanceId, true);
}
foreach (['Boiler1Link', 'Boiler2Link'] as $oldLinkIdent) {
$oldLink = enelixDemoFind($dashboard, $oldLinkIdent);
@@ -248,22 +361,25 @@ function enelixDemoInstall(): array
}
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);
enelixDemoEnsureLink($dashboard, 'EntfeuchterLink', 'Entfeuchter', $dryer, 150);
enelixDemoEnsureLink($simulation, 'Boiler1TemperaturLink', 'Boiler 1 Temperatur', $temp1, 100);
enelixDemoEnsureLink($simulation, 'Boiler2TemperaturLink', 'Boiler 2 Temperatur', $temp2, 110);
enelixDemoEnsureLink($simulation, 'PufferTemperaturLink', 'Puffertemperatur', $bufferTemp, 120);
enelixDemoEnsureLink($simulation, 'AussenTemperaturLink', 'Aussentemperatur', $outsideTemp, 130);
enelixDemoEnsureLink($simulation, 'BatterieLadezustandLink', 'Batterie Ladezustand', $batterySoc, 140);
enelixDemoConfigureArchive([
$pvDisplay, $houseDisplay, $loadDisplay, $grid,
$boiler1Power, $boiler2Power, $dryerPower,
$boiler1Power, $boiler2Power, $dryerPower, $bufferPower, $batteryPower,
$pvEnergy, $houseEnergy, $gridImportEnergy, $gridExportEnergy,
$temp1, $temp2,
$temp1, $temp2, $bufferTemp, $outsideTemp, $batterySoc, $batteryActual,
$flowPv, $flowGrid, $flowBase, $flowBoiler1, $flowBoiler2, $flowDryer,
$flowHouse, $flowLoads,
$flowBuffer, $flowBattery, $flowHouse, $flowLoads,
]);
enelixDemoEnsureChart($history, 'LeistungsdiagrammNativ', 'Leistungsverlauf', [
enelixDemoChartDataset($flowPv, 'PV', '#f4c542', '~Power'),
enelixDemoChartDataset($flowHouse, 'Hausverbrauch', '#2bb673', '~Power'),
enelixDemoChartDataset($flowLoads, 'Geregelte Verbraucher', '#2786ac', '~Power'),
enelixDemoChartDataset($flowLoads, 'EMS-Leistung inkl. Speicher', '#2786ac', '~Power'),
enelixDemoChartDataset($flowGrid, 'Netzleistung', '#e95d5d', '~Power'),
], 10);
enelixDemoEnsureChart($history, 'EnergiediagrammNativ', 'Kumulierte Energien', [
@@ -279,7 +395,7 @@ function enelixDemoInstall(): array
'rootId' => $root,
'visualizationId' => enelixDemoFirstInstance(ENELIX_DEMO_TILE_MODULE),
'managerId' => $manager,
'consumerIds' => [$boiler1, $boiler2, $dryer],
'consumerIds' => $consumerIds,
'tickScriptId' => $tickScript,
];
}
@@ -301,13 +417,20 @@ function enelixDemoTick(string $source = 'Manual'): void
SetValue($hourId, $hour);
}
$dynamic = (bool) GetValue(enelixDemoRequiredId($simulation, 'DynamischeProfile'));
$environment = enelixDemoEnvironment(
$hour,
(float) GetValue(enelixDemoRequiredId($simulation, 'Grundlast')),
(int) GetValue(enelixDemoRequiredId($simulation, 'Bewoelkung')),
$dynamic
);
$base = $environment['base']
+ (float) GetValue(enelixDemoRequiredId($simulation, 'Zusatzlast'));
$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));
$pv = $peak * $sun * (1.0 - ($environment['cloud'] * 0.0085));
$contacts = [
'boiler11' => enelixDemoRequiredId($technical, 'Boiler1Stufe1'),
@@ -315,15 +438,46 @@ function enelixDemoTick(string $source = 'Manual'): void
'boiler21' => enelixDemoRequiredId($technical, 'Boiler2Stufe1'),
'boiler22' => enelixDemoRequiredId($technical, 'Boiler2Stufe2'),
'dryer' => enelixDemoRequiredId($technical, 'EntfeuchterKontakt'),
'buffer1' => enelixDemoRequiredId($technical, 'PufferStufe1'),
'buffer2' => enelixDemoRequiredId($technical, 'PufferStufe2'),
'battery' => enelixDemoRequiredId($technical, 'BatterieIstleistung'),
];
$powerBefore = enelixDemoControlledPower($contacts);
$temp1 = enelixDemoRequiredId($technical, 'Boiler1Temperatur');
$temp2 = enelixDemoRequiredId($technical, 'Boiler2Temperatur');
$bufferTemp = enelixDemoRequiredId($technical, 'PufferTemperatur');
$outsideTemp = enelixDemoRequiredId($technical, 'AussenTemperatur');
$batterySoc = enelixDemoRequiredId($technical, 'BatterieLadezustand');
$batteryMaxCharge = enelixDemoRequiredId($technical, 'BatterieMaxLaden');
$batteryMaxDischarge = enelixDemoRequiredId($technical, 'BatterieMaxEntladen');
$previousBatteryPower = (float) GetValue($contacts['battery']);
SetValue($outsideTemp, round($environment['outside'], 2));
SetValue($batteryMaxCharge, GetValue($batteryMaxCharge));
SetValue($batteryMaxDischarge, GetValue($batteryMaxDischarge));
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);
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
);
enelixDemoUpdateBufferTemperature(
$bufferTemp,
(bool) GetValue($contacts['buffer2']) ? 3600 : ((bool) GetValue($contacts['buffer1']) ? 1800 : 0),
$environment['outside'],
$minutes
);
enelixDemoUpdateBatterySoc($batterySoc, $previousBatteryPower, $minutes);
} else {
SetValue($bufferTemp, GetValue($bufferTemp));
SetValue($batterySoc, GetValue($batterySoc));
}
$powerBefore = enelixDemoControlledPower($contacts);
$gridId = enelixDemoRequiredId($dashboard, 'Netzleistung');
SetValue($gridId, round($base + $powerBefore - $pv, 1));
@@ -331,38 +485,55 @@ function enelixDemoTick(string $source = 'Manual'): void
enelixDemoRequiredId($ems, 'Boiler1'),
enelixDemoRequiredId($ems, 'Boiler2'),
enelixDemoRequiredId($ems, 'Entfeuchter'),
enelixDemoRequiredId($ems, 'Pufferspeicher'),
enelixDemoRequiredId($ems, 'Batterie'),
];
$manager = enelixDemoRequiredId($ems, 'Manager');
$managerReady = (int) IPS_GetInstance($manager)['InstanceStatus'] === 102;
if ($managerReady) {
foreach ($consumers as $consumerId) {
IPS_RequestAction($consumerId, 'Melden', true);
}
$manager = enelixDemoRequiredId($ems, 'Manager');
IPS_RequestAction($manager, 'Regeln', true);
}
$battery = $managerReady
? enelixDemoBatteryRegisterPower(
(int) GetValue(enelixDemoRequiredId($technical, 'BatterieManagement')),
(int) GetValue(enelixDemoRequiredId($technical, 'BatterieModus')),
(float) GetValue(enelixDemoRequiredId($technical, 'BatterieRegisterLeistung'))
)
: 0.0;
SetValue($contacts['battery'], $battery);
$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;
$buffer = (bool) GetValue($contacts['buffer2']) ? 3600 : ((bool) GetValue($contacts['buffer1']) ? 1800 : 0);
$loads = $boiler1 + $boiler2 + $dryer + $buffer;
$emsPower = $loads + $battery;
$house = $base + $loads;
$grid = $house + $battery - $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, 'PVLeistung'), round($pv, 1));
SetValue(enelixDemoRequiredId($dashboard, 'Hausverbrauch'), round($house, 1));
SetValue(enelixDemoRequiredId($dashboard, 'Verbraucherleistung'), (float) $emsPower);
SetValue(enelixDemoRequiredId($dashboard, 'Boiler1Leistung'), (float) $boiler1);
SetValue(enelixDemoRequiredId($dashboard, 'Boiler2Leistung'), (float) $boiler2);
SetValue(enelixDemoRequiredId($dashboard, 'EntfeuchterLeistung'), (float) $dryer);
SetValue(enelixDemoRequiredId($dashboard, 'PufferLeistung'), (float) $buffer);
SetValue(enelixDemoRequiredId($dashboard, 'BatterieLeistung'), $battery);
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, 'EnergieflussPufferkW'), round($buffer / 1000.0, 3));
SetValue(enelixDemoRequiredId($technical, 'EnergieflussBatteriekW'), round($battery / 1000.0, 3));
SetValue(enelixDemoRequiredId($technical, 'DiagrammHausverbrauchkW'), round($house / 1000.0, 3));
SetValue(enelixDemoRequiredId($technical, 'DiagrammVerbraucherkW'), round($power / 1000.0, 3));
SetValue(enelixDemoRequiredId($technical, 'DiagrammVerbraucherkW'), round($emsPower / 1000.0, 3));
$history = enelixDemoRequiredId($root, 'Historie');
$energyIds = [
@@ -378,7 +549,6 @@ function enelixDemoTick(string $source = 'Manual'): void
enelixDemoAddEnergy($energyIds['import'], max(0.0, $grid), $hours);
enelixDemoAddEnergy($energyIds['export'], max(0.0, -$grid), $hours);
}
}
function enelixDemoRenderFlow(array $state): string
@@ -390,9 +560,15 @@ function enelixDemoRenderFlow(array $state): string
$gridLabel = $grid < -25 ? 'Einspeisung' : ($grid > 25 ? 'Netzbezug' : 'Ausgeglichen');
$gridArrow = $grid < -25 ? '&rarr;' : ($grid > 25 ? '&larr;' : '&harr;');
$gridClass = abs($grid) <= 250 ? 'good' : ($grid < 0 ? 'export' : 'import');
$buffer = (float) ($state['buffer'] ?? 0.0);
$battery = (float) ($state['battery'] ?? 0.0);
$bufferTemp = (float) ($state['bufferTemp'] ?? 36.0);
$batterySoc = (float) ($state['batterySoc'] ?? 50.0);
$b1Class = (float) $state['boiler1'] > 0 ? 'on' : '';
$b2Class = (float) $state['boiler2'] > 0 ? 'on' : '';
$dryClass = (float) $state['dryer'] > 0 ? 'on' : '';
$bufferClass = $buffer > 0 ? 'on' : '';
$batteryClass = abs($battery) > 25 ? '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}
@@ -403,14 +579,14 @@ function enelixDemoRenderFlow(array $state): string
.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-loads{display:grid;grid-template-columns:repeat(auto-fit,minmax(190px,1fr));gap:10px;margin-top:18px}
.enelix-load{border-left:4px solid #566371;background:#17202a;padding:13px 14px;border-radius:6px}
.enelix-load.on{border-left-color:#2bb673}.enelix-load b{display:block;font-size:17px;margin:4px 0}.enelix-load span{color:#9dacbd;font-size:12px}
.enelix-foot{display:flex;justify-content:space-between;gap:12px;margin-top:16px;padding-top:12px;border-top:1px solid #2a3541;color:#9dacbd;font-size:12px}
@media(max-width:640px){.enelix-line{grid-template-columns:1fr}.enelix-arrow{transform:rotate(90deg)}.enelix-loads{grid-template-columns:1fr}.enelix-head{align-items:start;flex-direction:column;gap:4px}}
</style><div class="enelix-flow"><div class="enelix-head"><div><div class="enelix-title">Enelix 2 Energiefluss</div><div class="enelix-sub">Manager regelt drei flexible Verbraucher</div></div><div class="enelix-sub">Simulation ' . $time . ' Uhr</div></div>
<div class="enelix-line"><div class="enelix-node solar"><small>PV-Erzeugung</small><b>' . $format((float) $state['pv']) . ' W</b></div><div class="enelix-arrow">&rarr;</div><div class="enelix-node bus"><small>Haus + flexible Lasten</small><b>' . $format((float) $state['base'] + (float) $state['boiler1'] + (float) $state['boiler2'] + (float) $state['dryer']) . ' W</b></div><div class="enelix-arrow">' . $gridArrow . '</div><div class="enelix-node ' . $gridClass . '"><small>' . $gridLabel . '</small><b>' . $format(abs($grid)) . ' W</b></div></div>
<div class="enelix-loads"><div class="enelix-load ' . $b1Class . '"><span>Priorität 1</span><b>Boiler 1 · ' . $format((float) $state['boiler1']) . ' W</b><span>' . number_format((float) $state['temp1'], 1, ',', '') . ' °C</span></div><div class="enelix-load ' . $b2Class . '"><span>Priorität 2</span><b>Boiler 2 · ' . $format((float) $state['boiler2']) . ' W</b><span>' . number_format((float) $state['temp2'], 1, ',', '') . ' °C</span></div><div class="enelix-load ' . $dryClass . '"><span>Priorität 3</span><b>Entfeuchter · ' . $format((float) $state['dryer']) . ' W</b><span>einstufiger Verbraucher</span></div></div>
</style><div class="enelix-flow"><div class="enelix-head"><div><div class="enelix-title">Enelix 2 Energiefluss</div><div class="enelix-sub">Manager regelt fünf flexible Teilnehmer aus vier Modultypen</div></div><div class="enelix-sub">Simulation ' . $time . ' Uhr</div></div>
<div class="enelix-line"><div class="enelix-node solar"><small>PV-Erzeugung</small><b>' . $format((float) $state['pv']) . ' W</b></div><div class="enelix-arrow">&rarr;</div><div class="enelix-node bus"><small>Haus + EMS-Leistung</small><b>' . $format((float) $state['base'] + (float) $state['boiler1'] + (float) $state['boiler2'] + (float) $state['dryer'] + $buffer + $battery) . ' 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 class="enelix-load ' . $bufferClass . '"><span>Priorität 4</span><b>Pufferspeicher · ' . $format($buffer) . ' W</b><span>' . number_format($bufferTemp, 1, ',', '') . ' °C · Heizkurve</span></div><div class="enelix-load ' . $batteryClass . '"><span>Priorität 5 / Peak 1</span><b>Batterie · ' . $format($battery) . ' W</b><span>' . number_format($batterySoc, 1, ',', '') . ' % · bidirektional</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>';
}
@@ -418,7 +594,65 @@ 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);
$buffer = (bool) GetValue($contacts['buffer2']) ? 3600 : ((bool) GetValue($contacts['buffer1']) ? 1800 : 0);
$battery = (int) round((float) GetValue($contacts['battery']));
return $boiler1 + $boiler2 + ((bool) GetValue($contacts['dryer']) ? 900 : 0) + $buffer + $battery;
}
/** @return array{base: float, cloud: float, outside: float} */
function enelixDemoEnvironment(float $hour, float $base, int $cloud, bool $dynamic): array
{
$hour = fmod(max(0.0, $hour), 24.0);
$cloud = min(100, max(0, $cloud));
if (!$dynamic) {
return ['base' => $base, 'cloud' => (float) $cloud, 'outside' => 6.0];
}
$morning = exp(-pow(($hour - 7.5) / 1.5, 2));
$evening = exp(-pow(($hour - 19.0) / 2.2, 2));
$night = ($hour < 5.0 || $hour >= 23.0) ? 0.65 : 0.82;
$baseFactor = $night + (0.48 * $morning) + (0.72 * $evening);
$cloudVariation = (18.0 * sin(($hour + 0.4) * 1.7)) + (8.0 * sin($hour * 4.6));
$outside = 7.0 + (6.0 * sin(2.0 * M_PI * ($hour - 9.0) / 24.0));
return [
'base' => max(150.0, $base * $baseFactor),
'cloud' => min(100.0, max(0.0, $cloud + $cloudVariation)),
'outside' => $outside,
];
}
function enelixDemoBatteryRegisterPower(int $management, int $mode, float $power): float
{
if ($management !== 2) {
return 0.0;
}
if ($mode === 11) {
return max(0.0, $power);
}
if ($mode === 12) {
return -max(0.0, $power);
}
return 0.0;
}
function enelixDemoUpdateBufferTemperature(int $variableId, int $power, float $outside, int $minutes): void
{
$current = (float) GetValue($variableId);
$heating = ($power / 1000.0) * 0.018 * $minutes;
$loss = max(0.0, $current - $outside) * 0.0007 * $minutes;
SetValue($variableId, round(min(65.0, max(22.0, $current + $heating - $loss)), 2));
}
function enelixDemoUpdateBatterySoc(int $variableId, float $power, int $minutes): void
{
$current = (float) GetValue($variableId);
$energy = ($power / 1000.0) * ($minutes / 60.0);
$storedEnergy = $energy >= 0.0 ? $energy * 0.94 : $energy / 0.94;
$delta = ($storedEnergy / 10.0) * 100.0;
SetValue($variableId, round(min(100.0, max(5.0, $current + $delta)), 2));
}
function enelixDemoUpdateTemperature(int $variableId, int $power, int $minutes): void
@@ -439,6 +673,8 @@ function enelixDemoValidateRuntime(): void
ENELIX_DEMO_MANAGER_MODULE,
ENELIX_DEMO_BOILER_MODULE,
ENELIX_DEMO_SINGLE_MODULE,
ENELIX_DEMO_BUFFER_MODULE,
ENELIX_DEMO_BATTERY_MODULE,
ENELIX_DEMO_DUMMY_MODULE,
ENELIX_DEMO_ENERGY_DISTRIBUTION_MODULE,
] as $moduleId) {
@@ -450,10 +686,11 @@ function enelixDemoValidateRuntime(): void
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.Power', 2, -20000, 20000, 100, 0, ' W');
enelixDemoEnsureProfile('ENELIX.Demo.PowerPositive', 2, 0, 20000, 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.PercentFloat', 2, 0, 100, 0.1, 1, ' %');
enelixDemoEnsureProfile('ENELIX.Demo.Speed', 1, 1, 60, 1, 0, ' min');
enelixDemoEnsureProfile('ENELIX.Demo.Energy', 2, 0, 1000000, 0.01, 2, ' kWh');
}
@@ -628,15 +865,34 @@ function enelixDemoEnsureCyclicEvent(int $scriptId, string $ident, int $seconds)
return $id;
}
function enelixDemoConfigure(int $instanceId, array $properties): void
function enelixDemoConfigure(
int $instanceId,
array $properties,
array $allowedStatuses = [102]
): 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));
$deadline = microtime(true) + 2.0;
do {
$status = (int) IPS_GetInstance($instanceId)['InstanceStatus'];
if ($status === 102) {
return;
}
IPS_Sleep(50);
} while (microtime(true) < $deadline);
if (in_array($status, $allowedStatuses, true)) {
return;
}
throw new RuntimeException(
'Instanz ist nach der Konfiguration nicht aktiv: '
. IPS_GetName($instanceId)
. ' (Status ' . $status . ', erwartet: ' . implode(', ', $allowedStatuses) . ')'
);
}
function enelixDemoConfigureArchive(array $variableIds): void
@@ -713,7 +969,7 @@ function enelixDemoRemove(bool $confirmed = false): void
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) {
foreach (['ENELIX.Demo.Power', 'ENELIX.Demo.PowerPositive', 'ENELIX.Demo.Hour', 'ENELIX.Demo.Percent', 'ENELIX.Demo.PercentFloat', 'ENELIX.Demo.Speed', 'ENELIX.Demo.Energy'] as $profile) {
if (IPS_VariableProfileExists($profile)) {
IPS_DeleteVariableProfile($profile);
}
@@ -771,9 +1027,14 @@ function enelixDemoConfigureEnergyDistribution(int $instanceId, array $nodes): v
]);
}
function enelixDemoLinkInstanceVariables(int $categoryId, int $instanceId, int $sensorId): void
{
enelixDemoEnsureLink($categoryId, 'SensorTemperatur', 'Gemessene Temperatur', $sensorId, 5);
function enelixDemoLinkInstanceVariables(
int $categoryId,
int $instanceId,
int $sensorId,
string $sensorName = 'Gemessene Temperatur',
string $sensorIdent = 'SensorTemperatur'
): void {
enelixDemoEnsureLink($categoryId, $sensorIdent, $sensorName, $sensorId, 5);
$position = 10;
foreach (IPS_GetChildrenIDs($instanceId) as $childId) {
if ((int) IPS_GetObject($childId)['ObjectType'] !== 2) {
+56
View File
@@ -82,6 +82,62 @@ final class Lizenzpruefung
}
}
/**
* Liefert die aufsummierte aktive Berechtigung fuer einen Katalogeintrag.
*
* @param array<string, mixed> $lease
*/
public static function berechtigungsMenge(array $lease, string $kennung): int
{
$berechtigungen = $lease['entitlements'] ?? null;
if (!is_array($berechtigungen)) {
return 0;
}
$menge = 0;
foreach ($berechtigungen as $berechtigung) {
if (!is_array($berechtigung)
|| ($berechtigung['status'] ?? null) !== 'active'
|| !is_int($berechtigung['quantity'] ?? null)
|| $berechtigung['quantity'] < 1
) {
continue;
}
$berechtigungsKennung = $berechtigung['catalog_key']
?? $berechtigung['sku']
?? null;
if ($berechtigungsKennung === $kennung) {
$menge += $berechtigung['quantity'];
}
}
return $menge;
}
/**
* @param array<string, mixed> $lease
* @param array<string, int> $verwendeteMengen
*/
public static function pruefeVerbrauchermengen(array $lease, array $verwendeteMengen): void
{
foreach ($verwendeteMengen as $kennung => $verwendet) {
if ($verwendet < 0) {
throw new InvalidArgumentException(
'Die verwendete Menge fuer ' . $kennung . ' darf nicht negativ sein.'
);
}
$lizenziert = self::berechtigungsMenge($lease, $kennung);
if ($verwendet > $lizenziert) {
throw new InvalidArgumentException(sprintf(
'Lizenzkontingent fuer %s ueberschritten: %d verwendet, %d lizenziert.',
$kennung,
$verwendet,
$lizenziert
));
}
}
}
/**
* @param array<string, mixed> $lease
*/
+46
View File
@@ -18,8 +18,12 @@ final class DemoanlageBootstrapTest extends TestCase
'boiler1' => 2400,
'boiler2' => 1500,
'dryer' => 900,
'buffer' => 1800,
'battery' => -1200,
'temp1' => 51.2,
'temp2' => 48.7,
'bufferTemp' => 39.4,
'batterySoc' => 63.5,
'manager' => 'PV',
]);
@@ -27,6 +31,9 @@ final class DemoanlageBootstrapTest extends TestCase
self::assertStringContainsString('Boiler 1', $html);
self::assertStringContainsString('Boiler 2', $html);
self::assertStringContainsString('Entfeuchter', $html);
self::assertStringContainsString('Pufferspeicher', $html);
self::assertStringContainsString('Batterie', $html);
self::assertStringContainsString('63,5 %', $html);
self::assertStringContainsString('Einspeisung', $html);
self::assertStringContainsString('12:30', $html);
}
@@ -105,6 +112,10 @@ final class DemoanlageBootstrapTest extends TestCase
self::assertStringContainsString('ENELIX_DEMO_DUMMY_MODULE', $source);
self::assertStringContainsString("'Boiler1Liste'", $source);
self::assertStringContainsString("'Boiler2Liste'", $source);
self::assertStringContainsString("'PufferListe'", $source);
self::assertStringContainsString("'BatterieListe'", $source);
self::assertStringContainsString('ENELIX_DEMO_BUFFER_MODULE', $source);
self::assertStringContainsString('ENELIX_DEMO_BATTERY_MODULE', $source);
self::assertStringContainsString("'EnergieflussPVkW'", $source);
self::assertStringContainsString("'~Power'", $source);
self::assertStringContainsString("'~Electricity'", $source);
@@ -120,6 +131,20 @@ final class DemoanlageBootstrapTest extends TestCase
"enelixDemoEnergyNode(\$flowDryer, 'Entfeuchter', 'consumer')",
$source
);
self::assertStringContainsString(
"enelixDemoEnergyNode(\$flowBuffer, 'Pufferspeicher', 'consumer', \$bufferTemp)",
$source
);
self::assertStringContainsString(
"enelixDemoEnergyNode(\$flowBattery, 'Batteriespeicher', 'battery', \$batterySoc)",
$source
);
self::assertStringContainsString(
"\$consumerIds = [\$boiler1, \$boiler2, \$dryer, \$buffer, \$battery]",
$source
);
self::assertStringContainsString("'consumerIds' => \$consumerIds", $source);
self::assertStringContainsString("'VerbraucherZuordnung' => '[]'", $source);
self::assertStringContainsString(
"enelixDemoEnsureChart(\$history, 'LeistungsdiagrammNativ'",
$source
@@ -135,6 +160,27 @@ final class DemoanlageBootstrapTest extends TestCase
);
}
public function testDynamischeUmgebungBleibtBegrenztUndVeraendertDenLastgang(): void
{
$static = enelixDemoEnvironment(12.0, 1000.0, 30, false);
self::assertSame(['base' => 1000.0, 'cloud' => 30.0, 'outside' => 6.0], $static);
$morning = enelixDemoEnvironment(7.5, 1000.0, 30, true);
$night = enelixDemoEnvironment(2.0, 1000.0, 30, true);
self::assertGreaterThan($night['base'], $morning['base']);
self::assertGreaterThanOrEqual(0.0, $morning['cloud']);
self::assertLessThanOrEqual(100.0, $morning['cloud']);
self::assertNotSame($morning['outside'], $night['outside']);
}
public function testBatterieregisterWerdenMitKorrektemVorzeichenAbgebildet(): void
{
self::assertSame(1500.0, enelixDemoBatteryRegisterPower(2, 11, 1500.0));
self::assertSame(-1500.0, enelixDemoBatteryRegisterPower(2, 12, 1500.0));
self::assertSame(0.0, enelixDemoBatteryRegisterPower(1, 11, 1500.0));
self::assertSame(0.0, enelixDemoBatteryRegisterPower(2, 0, 1500.0));
}
public function testRollbackIstExplizitBestaetigungspflichtig(): void
{
$this->expectException(RuntimeException::class);
+69
View File
@@ -104,6 +104,75 @@ final class LizenzpruefungTest extends TestCase
);
}
public function testZweiLizenzierteBoilerWerdenFreigegeben(): void
{
$lease = $this->lease('manager_standard');
$lease['entitlements'][] = [
'sku' => 'ENX-BOILER-MULTI',
'catalog_key' => 'boiler_multi',
'quantity' => 2,
'status' => 'active',
];
self::assertSame(2, Lizenzpruefung::berechtigungsMenge($lease, 'boiler_multi'));
Lizenzpruefung::pruefeVerbrauchermengen($lease, ['boiler_multi' => 2]);
self::assertTrue(true);
}
public function testDritterBoilerBeiZweiLizenzenWirdAbgelehnt(): void
{
$lease = $this->lease('manager_standard');
$lease['entitlements'][] = [
'sku' => 'ENX-BOILER-MULTI',
'catalog_key' => 'boiler_multi',
'quantity' => 2,
'status' => 'active',
];
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage(
'Lizenzkontingent fuer boiler_multi ueberschritten: 3 verwendet, 2 lizenziert.'
);
Lizenzpruefung::pruefeVerbrauchermengen($lease, ['boiler_multi' => 3]);
}
public function testVerbraucherkontingenteWerdenJeTypSummiertUndGetrenntGeprueft(): void
{
$lease = $this->lease('manager_standard');
$lease['entitlements'] = array_merge($lease['entitlements'], [
[
'catalog_key' => 'boiler_multi',
'quantity' => 1,
'status' => 'active',
],
[
'catalog_key' => 'boiler_multi',
'quantity' => 1,
'status' => 'active',
],
[
'catalog_key' => 'battery',
'quantity' => 1,
'status' => 'active',
],
[
'catalog_key' => 'battery',
'quantity' => 10,
'status' => 'revoked',
],
]);
self::assertSame(2, Lizenzpruefung::berechtigungsMenge($lease, 'boiler_multi'));
self::assertSame(1, Lizenzpruefung::berechtigungsMenge($lease, 'battery'));
Lizenzpruefung::pruefeVerbrauchermengen($lease, [
'boiler_multi' => 2,
'battery' => 1,
]);
self::assertTrue(true);
}
public function testLizenzcodeWirdWieAufDemServerNormalisiert(): void
{
self::assertSame(
+41
View File
@@ -52,6 +52,7 @@ final class ManagerModulstrukturTest extends TestCase
'LoggingEin',
'Lizenzcode',
'LizenzPruefen',
'LizenzVerwalten',
'LizenzInstallationID',
'LizenzInformation',
'VerbraucherAktualisieren',
@@ -69,6 +70,8 @@ final class ManagerModulstrukturTest extends TestCase
self::assertStringContainsString($bereich, $json);
}
self::assertSame('Lizenzierung', $formular['elements'][0]['caption']);
self::assertStringContainsString('https://license.enelix.ch', $json);
self::assertStringContainsString('"link":true', $json);
self::assertStringContainsString(
"'SuchbereichID' => \$SuchbereichID",
$json
@@ -111,6 +114,44 @@ final class ManagerModulstrukturTest extends TestCase
self::assertStringContainsString("RegisterAttributeString('LizenzcodeHash'", $inhalt);
self::assertStringContainsString('STATUS_LIZENZ_UNGUELTIG', $inhalt);
self::assertStringContainsString('aktualisiereLizenzfreigabe(', $inhalt);
self::assertStringContainsString('pruefeVerbraucherlizenzen(', $inhalt);
self::assertStringContainsString('lizenzBerechtigungenPassen(', $inhalt);
self::assertStringContainsString('peakShavingLizenziert(', $inhalt);
self::assertStringContainsString('Aus (Peak-Shaving-Lizenz fehlt)', $inhalt);
self::assertStringContainsString('Aus (Peak-Shaving-Lizenz verfuegbar)', $inhalt);
self::assertStringContainsString(
'Konstant und Monatlich benoetigen eine Peak-Shaving-Lizenz.',
$inhalt
);
foreach (['battery', 'buffer_storage', 'ev_charger', 'consumer_single', 'boiler_multi'] as $katalog) {
self::assertStringContainsString("'Katalog' => '" . $katalog . "'", $inhalt);
}
foreach ([
'{B7C54AF4-AD7D-4FE4-B75D-203693906251}',
'{15879A4E-D0C2-4495-83DE-46E1E462591E}',
'{0D94913C-0F31-4C29-A685-6EB4AE58E55D}',
'{32F65988-BE55-4FD6-9FFF-CF1C4C8628F8}',
'{C92D5EEF-9632-47A5-9659-4B02BF40FBE9}',
'{437FB683-517F-4FEC-8CCB-FE6B0A62B69E}',
] as $modulID) {
self::assertStringContainsString("'" . $modulID . "' =>", $inhalt);
}
self::assertStringContainsString("'Name' => 'Pufferspeicher'", $inhalt);
self::assertStringContainsString("'Name' => 'Batteriespeicher'", $inhalt);
self::assertStringContainsString(
'Bitte Auswahl reduzieren oder Lizenz erweitern.',
$inhalt
);
foreach ([
"'NichtGeregelt'",
"'NichtGeregelteGeraete'",
"'InstanzStatus'",
"'LetzteMeldung'",
"'GemeldeteBetriebsart'",
"'Verfuegbar'",
] as $detailfeld) {
self::assertStringContainsString($detailfeld, $inhalt);
}
self::assertStringNotContainsString("'publicKey' =>", $inhalt);
}
+16 -1
View File
@@ -163,7 +163,7 @@ return static function (TestContext $test): void {
if ($lizenzcode === '') {
$test->runCase('Manager bleibt ohne Lizenz sicher gesperrt', static function (
TestContext $test
) use ($managerId): void {
) use ($managerId, $verbraucherstatus): void {
IPS_RequestAction($managerId, 'Aktiv', true);
IPS_RequestAction($managerId, 'Regeln', true);
$test->assertInstanceStatus($managerId, 203);
@@ -171,6 +171,21 @@ return static function (TestContext $test): void {
'Inaktiv',
GetValue($test->objectByIdent('Betriebsart', $managerId))
);
$daten = $verbraucherstatus();
$test->assertSame(false, $daten['Synchronisiert'] ?? null);
$test->assertSame(3, count($daten['NichtGeregelt'] ?? []));
foreach ($daten['NichtGeregelt'] as $detail) {
$test->assertTrue(is_int($detail['InstanzID'] ?? null));
$test->assertTrue(is_string($detail['Name'] ?? null));
$test->assertTrue(is_string($detail['Typ'] ?? null));
$test->assertSame('Lizenz nicht freigegeben', $detail['Grund'] ?? null);
}
$detailtext = (string) GetValue(
$test->objectByIdent('NichtGeregelteGeraete', $managerId)
);
$test->assertTrue(str_contains($detailtext, 'Boiler'));
$test->assertTrue(str_contains($detailtext, 'Lizenz nicht freigegeben'));
});
return;