Verbrauchermodule und betriebsartabhängige Angebote ergänzen
Tests / test (push) Successful in 42s

This commit is contained in:
dh
2026-09-21 18:46:17 +00:00
parent 7b6d1b2f07
commit 3c71626b5f
46 changed files with 4513 additions and 311 deletions
+155
View File
@@ -0,0 +1,155 @@
{
"elements": [
{
"type": "ExpansionPanel",
"caption": "Manager und Zeitverhalten",
"expanded": true,
"items": [
{
"type": "NumberSpinner",
"name": "PrioritaetPV",
"caption": "Prioritaet PV",
"minimum": 0
},
{
"type": "NumberSpinner",
"name": "PrioritaetPeak",
"caption": "Prioritaet Peak",
"minimum": 0
},
{
"type": "NumberSpinner",
"name": "Meldeintervall",
"caption": "Meldeintervall",
"suffix": " s",
"minimum": 1
},
{
"type": "NumberSpinner",
"name": "VorgabeTimeout",
"caption": "Vorgabe-Timeout",
"suffix": " s",
"minimum": 1
},
{
"type": "NumberSpinner",
"name": "Abfrageintervall",
"caption": "Geraetestatus abfragen",
"suffix": " s",
"minimum": 1
}
]
},
{
"type": "ExpansionPanel",
"caption": "Ladestation",
"expanded": true,
"items": [
{
"type": "Select",
"name": "Geraetetyp",
"caption": "Geraetetyp",
"options": [
{
"caption": "Nicht konfiguriert",
"value": 0
},
{
"caption": "go-e Charger (alte API)",
"value": 1
},
{
"caption": "go-e Charger Gemini / Gemini flex",
"value": 2
},
{
"caption": "smart-me Pico",
"value": 3
}
]
},
{
"type": "ValidationTextBox",
"name": "Geraeteadresse",
"caption": "IP-Adresse oder Hostname (go-e)"
},
{
"type": "ValidationTextBox",
"name": "GeraeteID",
"caption": "Geraete-ID (Pico)"
},
{
"type": "ValidationTextBox",
"name": "Seriennummer",
"caption": "Seriennummer (Pico)"
},
{
"type": "ValidationTextBox",
"name": "Benutzername",
"caption": "Benutzername (Pico)"
},
{
"type": "PasswordTextBox",
"name": "Passwort",
"caption": "Passwort (Pico)"
},
{
"type": "NumberSpinner",
"name": "MaximalerLadestrom",
"caption": "Maximaler Ladestrom",
"suffix": " A",
"minimum": 6,
"maximum": 32
},
{
"type": "CheckBox",
"name": "Ladefreigabe",
"caption": "Ladefreigabe beim Start"
},
{
"type": "CheckBox",
"name": "Solarladen",
"caption": "Solarladen beim Start"
}
]
},
{
"type": "ExpansionPanel",
"caption": "Darstellung und Diagnose",
"items": [
{
"type": "CheckBox",
"name": "EinstellungenInVisu",
"caption": "Ladefreigabe und Solarladen in der Visualisierung anzeigen"
},
{
"type": "CheckBox",
"name": "DiagnosevariablenAnzeigen",
"caption": "Diagnosevariablen anzeigen"
},
{
"type": "CheckBox",
"name": "LoggingEin",
"caption": "Diagnoseprotokoll aktivieren"
}
]
}
],
"status": [
{
"code": 102,
"icon": "active",
"caption": "Aktiv"
},
{
"code": 201,
"icon": "error",
"caption": "Konfiguration ungueltig"
},
{
"code": 202,
"icon": "error",
"caption": "Geraetekommunikation fehlgeschlagen"
}
]
}
+14
View File
@@ -0,0 +1,14 @@
{
"id": "{0D94913C-0F31-4C29-A685-6EB4AE58E55D}",
"name": "LadestationStandAlone",
"type": 3,
"vendor": "Enelix",
"aliases": [
"Ladestation Stand-Alone"
],
"parentRequirements": [],
"childRequirements": [],
"implemented": [],
"prefix": "ENELIX",
"url": "https://git.belevo.ch/ENELIX/Enelix-EMS/src/branch/develop/LadestationStandAlone"
}
+763
View File
@@ -0,0 +1,763 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../libs/VerbraucherSchnittstelle.php';
require_once __DIR__ . '/../libs/VerbraucherBasisTrait.php';
require_once __DIR__ . '/../libs/Nachrichtenvertrag.php';
require_once __DIR__ . '/../libs/LadestationAdapter.php';
require_once __DIR__ . '/../libs/LadestationRegler.php';
use Belevo\EnelixEMS\LadestationAdapter;
use Belevo\EnelixEMS\LadestationRegler;
use Belevo\EnelixEMS\LadestationTransport;
use Belevo\EnelixEMS\Nachrichtenvertrag;
use Belevo\EnelixEMS\VerbraucherBasisTrait;
use Belevo\EnelixEMS\VerbraucherSchnittstelle;
class LadestationStandAlone extends IPSModule implements VerbraucherSchnittstelle
{
use VerbraucherBasisTrait;
private const MANAGER_MODULE_ID = '{6F771B18-59D4-4C8A-B951-3B2FE9F6A2C4}';
private const STATUS_AKTIV = 102;
private const STATUS_KONFIGURATION_UNGUELTIG = 201;
private const STATUS_KOMMUNIKATIONSFEHLER = 202;
/** @var list<string> */
private const DIAGNOSE_VARIABLEN = [
'Istleistung',
'Leistungsquelle',
'Sollleistung',
'SollwertGueltig',
'Verfuegbar',
'AenderungMoeglich',
'Stoerung',
'Stoertext',
'LetzterGeraetebefehl',
'LeistungsangebotDiagnose',
];
public function Create(): void
{
parent::Create();
$this->registriereVerbraucherBasis();
$this->RegisterPropertyInteger('Geraetetyp', 0);
$this->RegisterPropertyString('Geraeteadresse', '');
$this->RegisterPropertyString('GeraeteID', '');
$this->RegisterPropertyString('Seriennummer', '');
$this->RegisterPropertyString('Benutzername', '');
$this->RegisterPropertyString('Passwort', '');
$this->RegisterPropertyInteger('MaximalerLadestrom', 16);
$this->RegisterPropertyInteger('Abfrageintervall', 5);
$this->RegisterPropertyBoolean('Ladefreigabe', false);
$this->RegisterPropertyBoolean('Solarladen', true);
$this->RegisterPropertyBoolean('DiagnosevariablenAnzeigen', false);
// Ausschliesslich fuer den automatisierten Symcon-Funktionstest.
$this->RegisterPropertyBoolean('Testmodus', false);
$this->RegisterPropertyString('Testantwort', '');
$this->RegisterVariableBoolean('FahrzeugVerbunden', 'Fahrzeug verbunden', '~Switch', 100);
$this->RegisterVariableBoolean('FahrzeugGeladen', 'Fahrzeug geladen', '~Switch', 110);
$this->RegisterVariableInteger('Fahrzeugstatus', 'Fahrzeugstatus', '', 120);
$this->RegisterVariableFloat('Ladestrom', 'Ladestrom', '', 130);
$this->RegisterVariableInteger('Phasenzahl', 'Phasenzahl', '', 140);
$this->RegisterAttributeBoolean('InitialwerteGesetzt', false);
$this->RegisterAttributeBoolean('KonfigurationLadefreigabe', false);
$this->RegisterAttributeBoolean('KonfigurationSolarladen', true);
$this->RegisterAttributeBoolean('ZustandLadefreigabe', false);
$this->RegisterAttributeBoolean('ZustandSolarladen', true);
$this->RegisterAttributeBoolean('ZustandFahrzeugVerbunden', false);
$this->RegisterAttributeBoolean('ZustandFahrzeugGeladen', false);
$this->RegisterAttributeInteger('ZustandFahrzeugstatus', 0);
$this->RegisterAttributeFloat('ZustandLadestrom', 0.0);
$this->RegisterAttributeInteger('ZustandPhasenzahl', 0);
$this->RegisterAttributeFloat('ZustandIstleistung', 0.0);
$this->RegisterAttributeInteger('ZustandSollleistung', 0);
$this->RegisterAttributeBoolean('ZustandSollwertGueltig', false);
$this->RegisterAttributeBoolean('ZustandVerfuegbar', false);
$this->RegisterAttributeBoolean('ZustandAenderungMoeglich', false);
$this->RegisterAttributeBoolean('ZustandStoerung', false);
$this->RegisterAttributeString('ZustandStoertext', '');
$this->RegisterAttributeString('Leistungsangebot', '[0]');
$this->RegisterAttributeString('Betriebsart', Nachrichtenvertrag::BETRIEBSART_PV);
$this->RegisterAttributeInteger('LetzteManagerID', 0);
$this->RegisterAttributeInteger('LetzteVorgabeZeit', 0);
$this->RegisterAttributeInteger('LetzterGesetzterStrom', -1);
$this->RegisterTimer(
'Statuszyklus',
0,
"IPS_RequestAction(\$_IPS['TARGET'], 'Statuszyklus', false);"
);
$this->RegisterTimer(
'Meldezyklus',
0,
"IPS_RequestAction(\$_IPS['TARGET'], 'Melden', false);"
);
$this->RegisterTimer(
'RueckmeldungVerzoegert',
0,
"IPS_RequestAction(\$_IPS['TARGET'], 'Melden', true);"
);
$this->RegisterTimer(
'VorgabeTimeout',
0,
"IPS_RequestAction(\$_IPS['TARGET'], 'VorgabeTimeout', false);"
);
}
public function ApplyChanges(): void
{
parent::ApplyChanges();
$this->initialisiereLokaleEinstellungen();
$this->aktualisiereVariablen();
try {
$this->pruefeKonfiguration();
} catch (Throwable $fehler) {
$this->deaktiviereTimer();
$this->setzeZustand('Verfuegbar', false);
$this->setzeZustand('AenderungMoeglich', false);
$this->setzeStoerung($fehler->getMessage());
$this->SetStatus(self::STATUS_KONFIGURATION_UNGUELTIG);
$this->protokolliere('Konfiguration', $fehler->getMessage());
return;
}
$this->SetTimerInterval('Statuszyklus', $this->ReadPropertyInteger('Abfrageintervall') * 1000);
$this->SetTimerInterval('Meldezyklus', $this->ReadPropertyInteger('Meldeintervall') * 1000);
$this->aktualisiere(true);
}
public function RequestAction($ident, $wert): void
{
switch ($ident) {
case 'Aktiv':
$this->SetValue('Aktiv', (bool) $wert);
if (!(bool) $wert) {
$this->setzeZustand('SollwertGueltig', false);
}
$this->aktualisiere(true);
return;
case 'Ladefreigabe':
case 'Solarladen':
$this->WriteAttributeBoolean('Zustand' . $ident, (bool) $wert);
$this->setzeSichtbareVariable($ident, (bool) $wert);
$this->setzeZustand('SollwertGueltig', false);
$this->aktualisiere(true);
return;
case 'Statuszyklus':
$this->aktualisiere(true);
return;
case 'VorgabeTimeout':
$this->SetTimerInterval('VorgabeTimeout', 0);
$this->setzeZustand('SollwertGueltig', false);
$this->aktualisiere(true);
return;
case 'Melden':
if ((bool) $wert) {
$this->SetTimerInterval('RueckmeldungVerzoegert', 0);
}
$this->aktualisiere(false);
$this->sendeVerbraucherdaten();
return;
case 'ManagerdatenEmpfangen':
if (!is_string($wert)) {
throw new InvalidArgumentException('Managerdaten muessen als JSON uebergeben werden.');
}
$daten = json_decode($wert, true, 512, JSON_THROW_ON_ERROR);
if (!is_array($daten)) {
throw new InvalidArgumentException('Managerdaten muessen ein JSON-Objekt sein.');
}
$this->ManagerdatenEmpfangen($daten);
return;
}
throw new InvalidArgumentException('Unbekannte Aktion: ' . $ident);
}
/** @param array<string, mixed> $daten */
public function ManagerdatenEmpfangen(array $daten): void
{
Nachrichtenvertrag::pruefeManagerdaten($daten);
if ($daten['Kopf']['EmpfaengerID'] !== $this->InstanceID) {
throw new InvalidArgumentException('Managerdaten sind an eine andere Instanz adressiert.');
}
$managerID = $daten['Kopf']['AbsenderID'];
if (!in_array($managerID, $this->zugeordneteManagerIDs(), true)) {
throw new InvalidArgumentException('Der Manager hat diese Ladestation nicht aktiv zugeordnet.');
}
$this->WriteAttributeString('Betriebsart', $daten['Betriebsart']);
$this->aktualisiere(false, false);
$sollleistung = $daten['Sollleistung_W'];
if ($sollleistung === null) {
if ((bool) $this->leseZustand('SollwertGueltig')
&& !in_array((int) $this->leseZustand('Sollleistung'), $this->leseLeistungsangebot(), true)
) {
$this->setzeZustand('SollwertGueltig', false);
$this->aktualisiere(false, false);
}
$this->SetTimerInterval('RueckmeldungVerzoegert', 100);
return;
}
if (!in_array($sollleistung, $this->leseLeistungsangebot(), true)) {
throw new InvalidArgumentException(
'Sollleistung_W liegt nicht im aktuell gemeldeten Leistungsangebot.'
);
}
$this->WriteAttributeInteger('LetzteManagerID', $managerID);
$this->WriteAttributeInteger('LetzteVorgabeZeit', time());
$this->setzeZustand('Sollleistung', $sollleistung);
$this->setzeZustand('SollwertGueltig', true);
$this->SetTimerInterval('VorgabeTimeout', $this->ReadPropertyInteger('VorgabeTimeout') * 1000);
$this->aktualisiere(false, false);
$this->SetTimerInterval('RueckmeldungVerzoegert', 100);
}
private function aktualisiere(bool $meldungPlanen, bool $statusLesen = true): void
{
try {
if ($statusLesen) {
$this->leseGeraetestatus();
}
$aktiv = (bool) $this->GetValue('Aktiv');
$ladefreigabe = $this->ReadAttributeBoolean('ZustandLadefreigabe');
$verbunden = $this->ReadAttributeBoolean('ZustandFahrzeugVerbunden');
$geladen = $this->ReadAttributeBoolean('ZustandFahrzeugGeladen');
$phasenzahl = $this->ReadAttributeInteger('ZustandPhasenzahl');
$maximalstrom = $this->ReadPropertyInteger('MaximalerLadestrom');
$solarladen = $this->ReadAttributeBoolean('ZustandSolarladen');
$peakbetrieb = $this->ReadAttributeString('Betriebsart')
=== Nachrichtenvertrag::BETRIEBSART_PEAK;
$verfuegbar = LadestationRegler::leistungsangebot(
$aktiv,
$ladefreigabe,
$verbunden,
$geladen,
$phasenzahl,
$maximalstrom,
$solarladen,
false
) !== [0];
$angebot = LadestationRegler::leistungsangebot(
$aktiv,
$ladefreigabe,
$verbunden,
$geladen,
$phasenzahl,
$maximalstrom,
$solarladen,
$peakbetrieb
);
$angebotJson = json_encode($angebot, JSON_THROW_ON_ERROR);
$this->WriteAttributeString('Leistungsangebot', $angebotJson);
$this->setzeSichtbareVariable('LeistungsangebotDiagnose', $angebotJson);
$this->setzeZustand('Verfuegbar', $verfuegbar);
$this->setzeZustand('AenderungMoeglich', $verfuegbar && count($angebot) > 1);
$sollleistung = $this->bestimmeWirksameSollleistung($angebot);
$this->setzeLadestrom($sollleistung);
$this->setzeStoerung('');
$this->SetStatus(self::STATUS_AKTIV);
} catch (Throwable $fehler) {
$this->setzeZustand('Verfuegbar', false);
$this->setzeZustand('AenderungMoeglich', false);
$this->setzeStoerung($fehler->getMessage());
$this->SetStatus(self::STATUS_KOMMUNIKATIONSFEHLER);
$this->protokolliere('Geraetekommunikation', $fehler->getMessage());
}
if ($meldungPlanen) {
$this->SetTimerInterval('RueckmeldungVerzoegert', 100);
}
}
private function leseGeraetestatus(): void
{
$transport = new LadestationTransport(
fn (array $anfrage): string => $this->fuehreHttpAnfrageAus($anfrage)
);
$antwort = $transport->statusLesen(
$this->ReadPropertyInteger('Geraetetyp'),
$this->geraetekonfiguration()
);
$rohstatus = LadestationAdapter::dekodiereStatus(
$this->ReadPropertyInteger('Geraetetyp'),
$antwort
);
$status = LadestationRegler::normalisiereStatus(
$rohstatus,
$this->ReadAttributeBoolean('ZustandFahrzeugVerbunden')
);
foreach ($status as $ident => $wert) {
$this->setzeZustand($ident, $wert);
}
}
/** @param list<int> $angebot */
private function bestimmeWirksameSollleistung(array $angebot): int
{
if ((bool) $this->leseZustand('SollwertGueltig')) {
$sollleistung = (int) $this->leseZustand('Sollleistung');
if (in_array($sollleistung, $angebot, true)) {
return $sollleistung;
}
$this->setzeZustand('SollwertGueltig', false);
}
if (!$this->ReadAttributeBoolean('ZustandSolarladen') && $angebot !== [0]) {
return $angebot[count($angebot) - 1];
}
return 0;
}
private function setzeLadestrom(int $leistung): void
{
$phasenzahl = $this->ReadAttributeInteger('ZustandPhasenzahl');
$strom = $leistung === 0
? 0
: LadestationRegler::stromFuerLeistung($leistung, $phasenzahl);
if ($strom === $this->ReadAttributeInteger('LetzterGesetzterStrom')) {
return;
}
$transport = new LadestationTransport(
fn (array $anfrage): string => $this->fuehreHttpAnfrageAus($anfrage)
);
$transport->ladestromSetzen(
$this->ReadPropertyInteger('Geraetetyp'),
$this->geraetekonfiguration(),
$strom
);
$this->WriteAttributeInteger('LetzterGesetzterStrom', $strom);
}
/**
* @param array<string, mixed> $anfrage
*/
private function fuehreHttpAnfrageAus(array $anfrage): string
{
$protokoll = [
'Methode' => $anfrage['Methode'],
'URL' => $anfrage['URL'],
];
$this->setzeSichtbareVariable(
'LetzterGeraetebefehl',
json_encode($protokoll, JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR)
);
if ($this->ReadPropertyBoolean('Testmodus')) {
$url = (string) $anfrage['URL'];
if (str_ends_with($url, '/api/status')
|| str_ends_with($url, '/mqtt?payload=')
|| str_contains($url, '/pico/charging/')
) {
return $this->ReadPropertyString('Testantwort');
}
return '{}';
}
$curl = curl_init();
if ($curl === false) {
throw new RuntimeException('HTTP-Anfrage konnte nicht initialisiert werden.');
}
curl_setopt($curl, CURLOPT_URL, (string) $anfrage['URL']);
curl_setopt($curl, CURLOPT_RETURNTRANSFER, true);
curl_setopt($curl, CURLOPT_CONNECTTIMEOUT, 5);
curl_setopt($curl, CURLOPT_TIMEOUT, 10);
if ($anfrage['Methode'] === 'POST') {
curl_setopt($curl, CURLOPT_POST, true);
}
if (isset($anfrage['Benutzername'], $anfrage['Passwort'])) {
curl_setopt($curl, CURLOPT_HTTPAUTH, CURLAUTH_BASIC);
curl_setopt(
$curl,
CURLOPT_USERPWD,
(string) $anfrage['Benutzername'] . ':' . (string) $anfrage['Passwort']
);
}
$antwort = curl_exec($curl);
if ($antwort === false) {
$fehler = curl_error($curl);
curl_close($curl);
throw new RuntimeException('HTTP-Anfrage fehlgeschlagen: ' . $fehler);
}
$status = (int) curl_getinfo($curl, CURLINFO_HTTP_CODE);
curl_close($curl);
if ($status >= 400) {
throw new RuntimeException('Geraet antwortet mit HTTP-Status ' . $status . '.');
}
return (string) $antwort;
}
/** @return array<string, string> */
private function geraetekonfiguration(): array
{
return [
'Geraeteadresse' => $this->ReadPropertyString('Geraeteadresse'),
'GeraeteID' => $this->ReadPropertyString('GeraeteID'),
'Seriennummer' => $this->ReadPropertyString('Seriennummer'),
'Benutzername' => $this->ReadPropertyString('Benutzername'),
'Passwort' => $this->ReadPropertyString('Passwort'),
];
}
private function sendeVerbraucherdaten(): void
{
foreach ($this->zugeordneteManagerIDs() as $managerID) {
$daten = $this->baueVerbraucherdaten($managerID);
Nachrichtenvertrag::pruefeVerbraucherdaten($daten);
try {
IPS_RequestAction(
$managerID,
'VerbraucherdatenEmpfangen',
json_encode($daten, JSON_THROW_ON_ERROR)
);
} catch (Throwable $fehler) {
$this->protokolliere('Managerkommunikation', $fehler->getMessage());
}
}
}
/** @return array<string, mixed> */
private function baueVerbraucherdaten(int $managerID): array
{
$stoerung = (bool) $this->leseZustand('Stoerung');
return [
'Kopf' => [
'Version' => Nachrichtenvertrag::VERSION,
'AbsenderID' => $this->InstanceID,
'EmpfaengerID' => $managerID,
'Zeitpunkt' => time(),
],
'Betriebsart' => $this->ReadAttributeString('Betriebsart'),
'PrioritaetPV' => $this->ReadPropertyInteger('PrioritaetPV'),
'PrioritaetPeak' => $this->ReadPropertyInteger('PrioritaetPeak'),
'Leistungswerte_W' => $this->leseLeistungsangebot(),
'AenderungMoeglich' => (bool) $this->leseZustand('AenderungMoeglich'),
'Verfuegbar' => (bool) $this->leseZustand('Verfuegbar'),
'Istleistung_W' => (float) $this->leseZustand('Istleistung'),
'Leistungsquelle' => Nachrichtenvertrag::LEISTUNGSQUELLE_GEMESSEN,
'Zustand' => [
[
'Kennung' => 'Sollleistung_W',
'Art' => 'Sollwert',
'Wert' => (bool) $this->leseZustand('SollwertGueltig')
? (int) $this->leseZustand('Sollleistung')
: null,
'Einheit' => 'W',
],
[
'Kennung' => 'FahrzeugVerbunden',
'Art' => 'Status',
'Wert' => (bool) $this->leseZustand('FahrzeugVerbunden'),
'Einheit' => '',
],
[
'Kennung' => 'FahrzeugGeladen',
'Art' => 'Status',
'Wert' => (bool) $this->leseZustand('FahrzeugGeladen'),
'Einheit' => '',
],
[
'Kennung' => 'Fahrzeugstatus',
'Art' => 'Status',
'Wert' => (int) $this->leseZustand('Fahrzeugstatus'),
'Einheit' => '',
],
[
'Kennung' => 'Phasenzahl',
'Art' => 'Status',
'Wert' => (int) $this->leseZustand('Phasenzahl'),
'Einheit' => '',
],
[
'Kennung' => 'Ladestrom_A',
'Art' => 'Istwert',
'Wert' => (float) $this->leseZustand('Ladestrom'),
'Einheit' => 'A',
],
[
'Kennung' => 'Ladefreigabe',
'Art' => 'Status',
'Wert' => $this->ReadAttributeBoolean('ZustandLadefreigabe'),
'Einheit' => '',
],
[
'Kennung' => 'Solarladen',
'Art' => 'Status',
'Wert' => $this->ReadAttributeBoolean('ZustandSolarladen'),
'Einheit' => '',
],
[
'Kennung' => 'Ladefehler',
'Art' => 'Stoerung',
'Wert' => $stoerung,
'Einheit' => '',
'Text' => (string) $this->leseZustand('Stoertext'),
],
],
];
}
/** @return list<int> */
private function zugeordneteManagerIDs(): array
{
$ergebnis = [];
foreach (IPS_GetInstanceListByModuleID(self::MANAGER_MODULE_ID) as $managerID) {
try {
$automatisch = (bool) IPS_GetProperty($managerID, 'AutomatischeSuche');
$property = $automatisch ? 'AutomatischeVerbraucherZuordnung' : 'VerbraucherZuordnung';
$zuordnung = $this->leseManagerZuordnung($managerID, $property);
if ($automatisch && $zuordnung === []) {
$zuordnung = $this->leseManagerZuordnung($managerID, 'VerbraucherZuordnung');
}
} catch (Throwable $fehler) {
continue;
}
foreach ($zuordnung as $eintrag) {
if (!is_array($eintrag) || ($eintrag['Aktiv'] ?? false) !== true) {
continue;
}
$instanzID = $eintrag['InstanzID'] ?? $eintrag['Verbraucher'] ?? null;
if ($instanzID === $this->InstanceID) {
$ergebnis[] = (int) $managerID;
break;
}
}
}
return array_values(array_unique($ergebnis));
}
/** @return array<mixed> */
private function leseManagerZuordnung(int $managerID, string $property): array
{
$zuordnung = json_decode(
(string) IPS_GetProperty($managerID, $property),
true,
512,
JSON_THROW_ON_ERROR
);
return is_array($zuordnung) ? $zuordnung : [];
}
private function pruefeKonfiguration(): void
{
foreach (['PrioritaetPV', 'PrioritaetPeak'] as $property) {
if ($this->ReadPropertyInteger($property) < 0) {
throw new InvalidArgumentException($property . ' muss mindestens 0 sein.');
}
}
foreach (['Meldeintervall', 'VorgabeTimeout', 'Abfrageintervall'] as $property) {
if ($this->ReadPropertyInteger($property) <= 0) {
throw new InvalidArgumentException($property . ' muss groesser als 0 sein.');
}
}
$maximalstrom = $this->ReadPropertyInteger('MaximalerLadestrom');
if ($maximalstrom < 6 || $maximalstrom > 32) {
throw new InvalidArgumentException('MaximalerLadestrom muss zwischen 6 und 32 A liegen.');
}
$geraetetyp = $this->ReadPropertyInteger('Geraetetyp');
$konfiguration = $this->geraetekonfiguration();
LadestationAdapter::statusAnfrage($geraetetyp, $konfiguration);
LadestationAdapter::steuerAnfragen($geraetetyp, $konfiguration, 0);
}
private function initialisiereLokaleEinstellungen(): void
{
$initialisiert = $this->ReadAttributeBoolean('InitialwerteGesetzt');
$ladefreigabe = $this->ReadPropertyBoolean('Ladefreigabe');
$solarladen = $this->ReadPropertyBoolean('Solarladen');
if (!$initialisiert
|| $ladefreigabe !== $this->ReadAttributeBoolean('KonfigurationLadefreigabe')
) {
$this->WriteAttributeBoolean('ZustandLadefreigabe', $ladefreigabe);
}
if (!$initialisiert
|| $solarladen !== $this->ReadAttributeBoolean('KonfigurationSolarladen')
) {
$this->WriteAttributeBoolean('ZustandSolarladen', $solarladen);
}
$this->WriteAttributeBoolean('KonfigurationLadefreigabe', $ladefreigabe);
$this->WriteAttributeBoolean('KonfigurationSolarladen', $solarladen);
$this->WriteAttributeBoolean('InitialwerteGesetzt', true);
}
private function aktualisiereVariablen(): void
{
if ($this->ReadPropertyBoolean('EinstellungenInVisu')) {
$this->RegisterVariableBoolean('Ladefreigabe', 'Ladefreigabe', '~Switch', 150);
$this->RegisterVariableBoolean('Solarladen', 'Solarladen', '~Switch', 160);
$this->EnableAction('Ladefreigabe');
$this->EnableAction('Solarladen');
$this->SetValue('Ladefreigabe', $this->ReadAttributeBoolean('ZustandLadefreigabe'));
$this->SetValue('Solarladen', $this->ReadAttributeBoolean('ZustandSolarladen'));
} else {
$this->entferneVariable('Ladefreigabe');
$this->entferneVariable('Solarladen');
}
if ($this->ReadPropertyBoolean('DiagnosevariablenAnzeigen')) {
$this->registriereVerbraucherDiagnose();
$this->RegisterVariableString('LetzterGeraetebefehl', 'Letzter Geraetebefehl', '', 200);
$this->RegisterVariableString('LeistungsangebotDiagnose', 'Leistungsangebot', '', 210);
$this->SetValue(
'LeistungsangebotDiagnose',
$this->ReadAttributeString('Leistungsangebot')
);
foreach (self::DIAGNOSE_VARIABLEN as $ident) {
if (!in_array($ident, ['LetzterGeraetebefehl', 'LeistungsangebotDiagnose'], true)) {
$this->setzeSichtbareVariable($ident, $this->leseZustand($ident));
}
}
} else {
foreach (self::DIAGNOSE_VARIABLEN as $ident) {
$this->entferneVariable($ident);
}
}
}
private function entferneVariable(string $ident): void
{
$id = @$this->GetIDForIdent($ident);
if (is_int($id) && $id > 0 && IPS_VariableExists($id)) {
$this->UnregisterVariable($ident);
}
}
/** @param mixed $wert */
private function setzeZustand(string $ident, $wert): void
{
switch ($ident) {
case 'FahrzeugVerbunden':
case 'FahrzeugGeladen':
case 'SollwertGueltig':
case 'Verfuegbar':
case 'AenderungMoeglich':
case 'Stoerung':
$this->WriteAttributeBoolean('Zustand' . $ident, (bool) $wert);
break;
case 'Fahrzeugstatus':
case 'Phasenzahl':
case 'Sollleistung':
$this->WriteAttributeInteger('Zustand' . $ident, (int) $wert);
break;
case 'Ladestrom_A':
$ident = 'Ladestrom';
$this->WriteAttributeFloat('ZustandLadestrom', (float) $wert);
break;
case 'Istleistung_W':
$ident = 'Istleistung';
$this->WriteAttributeFloat('ZustandIstleistung', (float) $wert);
break;
case 'Stoertext':
$this->WriteAttributeString('ZustandStoertext', (string) $wert);
break;
default:
throw new LogicException('Unbekannter Zustand: ' . $ident);
}
$this->setzeSichtbareVariable($ident, $wert);
if ($ident === 'Istleistung') {
$this->setzeSichtbareVariable(
'Leistungsquelle',
Nachrichtenvertrag::LEISTUNGSQUELLE_GEMESSEN
);
}
}
/** @return mixed */
private function leseZustand(string $ident)
{
if ($ident === 'Ladefreigabe' || $ident === 'Solarladen') {
return $this->ReadAttributeBoolean('Zustand' . $ident);
}
if (in_array($ident, [
'FahrzeugVerbunden', 'FahrzeugGeladen', 'SollwertGueltig',
'Verfuegbar', 'AenderungMoeglich', 'Stoerung',
], true)) {
return $this->ReadAttributeBoolean('Zustand' . $ident);
}
if (in_array($ident, ['Fahrzeugstatus', 'Phasenzahl', 'Sollleistung'], true)) {
return $this->ReadAttributeInteger('Zustand' . $ident);
}
if ($ident === 'Ladestrom' || $ident === 'Istleistung') {
return $this->ReadAttributeFloat('Zustand' . $ident);
}
if ($ident === 'Stoertext') {
return $this->ReadAttributeString('ZustandStoertext');
}
if ($ident === 'Leistungsquelle') {
return Nachrichtenvertrag::LEISTUNGSQUELLE_GEMESSEN;
}
throw new LogicException('Unbekannter Zustand: ' . $ident);
}
/** @param mixed $wert */
private function setzeSichtbareVariable(string $ident, $wert): void
{
$id = @$this->GetIDForIdent($ident);
if (is_int($id) && $id > 0 && IPS_VariableExists($id)) {
$this->SetValue($ident, $wert);
}
}
/** @return list<int> */
private function leseLeistungsangebot(): array
{
$angebot = json_decode(
$this->ReadAttributeString('Leistungsangebot'),
true,
512,
JSON_THROW_ON_ERROR
);
return is_array($angebot) ? array_map('intval', $angebot) : [0];
}
private function setzeStoerung(string $text): void
{
$this->setzeZustand('Stoerung', $text !== '');
$this->setzeZustand('Stoertext', $text);
}
private function deaktiviereTimer(): void
{
foreach (['Statuszyklus', 'Meldezyklus', 'RueckmeldungVerzoegert', 'VorgabeTimeout'] as $timer) {
$this->SetTimerInterval($timer, 0);
}
}
private function protokolliere(string $bereich, string $nachricht): void
{
if (!$this->ReadPropertyBoolean('LoggingEin')) {
return;
}
$this->SendDebug($bereich, $nachricht, 0);
}
}
+58 -17
View File
@@ -24,6 +24,8 @@ class Manager extends IPSModule implements ManagerSchnittstelle
private const VERBRAUCHER_MODUL_IDS = [
'{B7C54AF4-AD7D-4FE4-B75D-203693906251}',
'{15879A4E-D0C2-4495-83DE-46E1E462591E}',
'{0D94913C-0F31-4C29-A685-6EB4AE58E55D}',
'{C92D5EEF-9632-47A5-9659-4B02BF40FBE9}',
];
private const MONATSNAMEN = [
1 => 'Januar', 'Februar', 'Maerz', 'April', 'Mai', 'Juni',
@@ -357,25 +359,44 @@ class Manager extends IPSModule implements ManagerSchnittstelle
return;
}
$stoerungen = [];
$verbraucher = $this->aktuelleVerbraucher($stoerungen);
$lastspitzengrenze = ManagerRegler::wirksameLastspitzengrenze(
$this->ReadPropertyInteger('Lastspitzenmodus'),
$this->ReadPropertyFloat('Lastspitzengrenze'),
$this->dekodiereMonatsgrenzen($this->ReadPropertyString('Monatsgrenzen')),
(int) date('n')
);
$bisherigeBetriebsart = (string) $this->GetValue('Betriebsart');
$betriebsart = ManagerRegler::bestimmeBetriebsart(
true,
true,
$netzleistung,
(string) $this->GetValue('Betriebsart'),
$bisherigeBetriebsart,
$lastspitzengrenze !== null,
$this->ReadPropertyFloat('SollwertSolarladen'),
$lastspitzengrenze ?? 0.0,
$this->ReadPropertyFloat('Umschaltdifferenz')
);
$this->SetValue('Betriebsart', $betriebsart);
if ($betriebsart !== $bisherigeBetriebsart) {
$this->WriteAttributeString('LetzteSollwerte', '{}');
}
$stoerungen = [];
$synchronisiert = true;
$verbraucher = $this->aktuelleVerbraucher($stoerungen, $betriebsart, $synchronisiert);
$this->setzeDiagnosewert('WirksameLastspitzengrenze', $lastspitzengrenze ?? 0.0);
if (!$synchronisiert) {
$this->setzeDiagnosewert('Verteilbudget', 0.0);
$this->setzeDiagnosewert('Verbraucherstatus', json_encode([
'Betriebsart' => $betriebsart,
'Synchronisiert' => false,
'Verbraucher' => $verbraucher,
], JSON_THROW_ON_ERROR));
$this->aktualisiereStoerungen($verbraucher, $stoerungen);
$this->SetStatus(self::STATUS_AKTIV);
return;
}
$zielnetzleistung = $betriebsart === ManagerRegler::BETRIEBSART_PEAK
? (float) $lastspitzengrenze
: $this->ReadPropertyFloat('SollwertSolarladen');
@@ -385,12 +406,10 @@ class Manager extends IPSModule implements ManagerSchnittstelle
$verbraucher,
$betriebsart === ManagerRegler::BETRIEBSART_PEAK ? 'PrioritaetPeak' : 'PrioritaetPV'
);
$this->SetValue('Betriebsart', $betriebsart);
$this->setzeDiagnosewert('WirksameLastspitzengrenze', $lastspitzengrenze ?? 0.0);
$this->setzeDiagnosewert('Verteilbudget', (float) $ergebnis['Verteilbudget_W']);
$this->setzeDiagnosewert('Verbraucherstatus', json_encode([
'Betriebsart' => $betriebsart,
'Synchronisiert' => true,
'Restabweichung_W' => $ergebnis['Restabweichung_W'],
'Verbraucher' => $verbraucher,
], JSON_THROW_ON_ERROR));
@@ -401,7 +420,7 @@ class Manager extends IPSModule implements ManagerSchnittstelle
continue;
}
try {
$this->sendeSollwert((int) $instanzID, $sollleistung);
$this->sendeManagerdaten((int) $instanzID, $betriebsart, $sollleistung);
$letzteSollwerte[(string) $instanzID] = $sollleistung;
} catch (Throwable $fehler) {
$stoerungen[] = 'Sollwert an Instanz ' . $instanzID . ': ' . $fehler->getMessage();
@@ -437,8 +456,11 @@ class Manager extends IPSModule implements ManagerSchnittstelle
* @param list<string> $stoerungen
* @return list<array{InstanzID: int, Daten: array<string, mixed>}>
*/
private function aktuelleVerbraucher(array &$stoerungen): array
{
private function aktuelleVerbraucher(
array &$stoerungen,
string $betriebsart,
bool &$synchronisiert
): array {
$ids = $this->aktiveVerbraucherIDs();
$this->setzeDiagnosewert('VerbraucherAnzahl', count($ids));
$cache = $this->leseJsonAttribut('VerbraucherCache');
@@ -447,12 +469,23 @@ class Manager extends IPSModule implements ManagerSchnittstelle
foreach ($ids as $instanzID) {
$eintrag = $cache[(string) $instanzID] ?? null;
$grund = '';
if (!is_array($eintrag) || !isset($eintrag['EmpfangenAm'], $eintrag['Daten'])) {
$stoerungen[] = 'Noch keine Meldung von Instanz ' . $instanzID . '.';
continue;
$grund = 'Noch keine Meldung';
} elseif (time() - (int) $eintrag['EmpfangenAm'] > $timeout) {
$grund = 'Meldung ist veraltet';
} elseif (($eintrag['Daten']['Betriebsart'] ?? null) !== $betriebsart) {
$grund = 'Leistungsangebot gehoert nicht zur Betriebsart ' . $betriebsart;
}
if (time() - (int) $eintrag['EmpfangenAm'] > $timeout) {
$stoerungen[] = 'Meldung von Instanz ' . $instanzID . ' ist veraltet.';
if ($grund !== '') {
$synchronisiert = false;
$stoerungen[] = $grund . ' von Instanz ' . $instanzID . '.';
try {
$this->sendeManagerdaten($instanzID, $betriebsart, null);
} catch (Throwable $fehler) {
$stoerungen[] = 'Betriebsart an Instanz ' . $instanzID . ': ' . $fehler->getMessage();
}
continue;
}
$aktuell[] = ['InstanzID' => $instanzID, 'Daten' => $eintrag['Daten']];
@@ -624,8 +657,11 @@ class Manager extends IPSModule implements ManagerSchnittstelle
return false;
}
private function sendeSollwert(int $instanzID, int $sollleistung): void
{
private function sendeManagerdaten(
int $instanzID,
string $betriebsart,
?int $sollleistung
): void {
if (!IPS_InstanceExists($instanzID)) {
throw new RuntimeException('Instanz existiert nicht.');
}
@@ -636,11 +672,16 @@ class Manager extends IPSModule implements ManagerSchnittstelle
'EmpfaengerID' => $instanzID,
'Zeitpunkt' => time(),
],
'Betriebsart' => $betriebsart,
'Sollleistung_W' => $sollleistung,
];
Nachrichtenvertrag::pruefeManagerdaten($paket);
IPS_RequestAction($instanzID, 'ManagerdatenEmpfangen', json_encode($paket, JSON_THROW_ON_ERROR));
$this->protokolliere('Sollwert', ['InstanzID' => $instanzID, 'Sollleistung_W' => $sollleistung]);
$this->protokolliere('Managerdaten', [
'InstanzID' => $instanzID,
'Betriebsart' => $betriebsart,
'Sollleistung_W' => $sollleistung,
]);
}
/**
+163
View File
@@ -0,0 +1,163 @@
{
"elements": [
{
"type": "ExpansionPanel",
"caption": "Manager und Zeitverhalten",
"expanded": true,
"items": [
{"type": "NumberSpinner", "name": "PrioritaetPV", "caption": "Prioritaet PV", "minimum": 0},
{"type": "NumberSpinner", "name": "PrioritaetPeak", "caption": "Prioritaet Peak", "minimum": 0},
{"type": "NumberSpinner", "name": "Meldeintervall", "caption": "Meldeintervall", "suffix": " s", "minimum": 1},
{"type": "NumberSpinner", "name": "VorgabeTimeout", "caption": "Vorgabe-Timeout", "suffix": " s", "minimum": 1},
{"type": "NumberSpinner", "name": "LastwechselSperrzeit", "caption": "Lastwechsel-Sperrzeit", "suffix": " s", "minimum": 0}
]
},
{
"type": "ExpansionPanel",
"caption": "Pufferspeicher",
"expanded": true,
"items": [
{
"type": "List",
"name": "LeistungsStufen",
"caption": "Leistungsstufen",
"add": true,
"delete": true,
"columns": [
{
"caption": "Stufe",
"name": "Stufe",
"width": "100px",
"add": 1,
"edit": {"type": "NumberSpinner", "minimum": 1}
},
{
"caption": "Leistung",
"name": "Leistung",
"width": "180px",
"add": 0,
"edit": {"type": "NumberSpinner", "minimum": 1, "suffix": " W"}
},
{
"caption": "Schaltkontakt",
"name": "Schaltkontakt_Stufe",
"width": "300px",
"add": 0,
"edit": {"type": "SelectVariable", "validVariableTypes": [0]}
}
]
},
{
"type": "SelectVariable",
"name": "PufferfuehlerVariableID",
"caption": "Puffertemperatur",
"validVariableTypes": [1, 2]
},
{
"type": "SelectVariable",
"name": "AussentemperaturVariableID",
"caption": "Aussentemperatur",
"validVariableTypes": [1, 2]
}
]
},
{
"type": "ExpansionPanel",
"caption": "Heizkurve",
"expanded": true,
"items": [
{
"type": "NumberSpinner",
"name": "FusspunktVorlauftemperatur",
"caption": "Vorlauftemperatur bei 20 Grad C aussen",
"suffix": " Grad C",
"digits": 1
},
{
"type": "NumberSpinner",
"name": "HeizkurvenSteigung",
"caption": "Heizkurvensteigung",
"minimum": 0,
"digits": 2
},
{
"type": "NumberSpinner",
"name": "HeizkurveMinimaltemperatur",
"caption": "Untere Begrenzung der Solltemperatur",
"suffix": " Grad C",
"digits": 1
},
{
"type": "NumberSpinner",
"name": "HeizkurveMaximaltemperatur",
"caption": "Obere Begrenzung der Solltemperatur",
"suffix": " Grad C",
"digits": 1
},
{
"type": "NumberSpinner",
"name": "Hysterese",
"caption": "Hysterese",
"suffix": " K",
"minimum": 0.1,
"digits": 1
},
{
"type": "Select",
"name": "MindesttemperaturModus",
"caption": "Optionale Mindesttemperatur",
"options": [
{"caption": "Aus", "value": 0},
{"caption": "Statisch", "value": 1},
{"caption": "Differenz zur Solltemperatur", "value": 2}
]
},
{
"type": "NumberSpinner",
"name": "Mindesttemperatur",
"caption": "Statische Mindesttemperatur",
"suffix": " Grad C",
"digits": 1
},
{
"type": "NumberSpinner",
"name": "MindesttemperaturDifferenz",
"caption": "Differenz zur Solltemperatur",
"suffix": " K",
"minimum": 0,
"digits": 1
},
{
"type": "SelectVariable",
"name": "WaermepumpenSolltemperaturVariableID",
"caption": "Optionale Solltemperatur der Waermepumpe",
"validVariableTypes": [1, 2]
},
{
"type": "Button",
"name": "HeizkurveVonWaermepumpeUebernehmen",
"caption": "Wert von Waermepumpe uebernehmen",
"onClick": "IPS_RequestAction($id, 'HeizkurveVonWaermepumpeUebernehmen', 0);"
}
]
},
{
"type": "ExpansionPanel",
"caption": "Erweiterte Einstellungen",
"items": [
{"type": "NumberSpinner", "name": "TemperaturMaxAlter", "caption": "Maximales Temperaturalter", "suffix": " s", "minimum": 1},
{"type": "CheckBox", "name": "PuffertemperaturGlaetten", "caption": "Puffertemperatur glaetten"},
{"type": "NumberSpinner", "name": "ZeitKonstante", "caption": "PT1-Zeitkonstante", "suffix": " s", "minimum": 1},
{"type": "CheckBox", "name": "EinstellungenInVisu", "caption": "Einstellungen in der Visualisierung anzeigen"},
{"type": "CheckBox", "name": "DiagnosevariablenAnzeigen", "caption": "Diagnosevariablen anzeigen"},
{"type": "CheckBox", "name": "LoggingEin", "caption": "Diagnoseprotokoll aktivieren"}
]
}
],
"status": [
{"code": 102, "icon": "active", "caption": "Aktiv"},
{"code": 201, "icon": "error", "caption": "Temperaturmessung ungueltig"},
{"code": 202, "icon": "error", "caption": "Konfiguration ungueltig"},
{"code": 203, "icon": "error", "caption": "Schaltfehler"}
]
}
+14
View File
@@ -0,0 +1,14 @@
{
"id": "{C92D5EEF-9632-47A5-9659-4B02BF40FBE9}",
"name": "VerbraucherPufferspeicher",
"type": 3,
"vendor": "Enelix",
"aliases": [
"Pufferspeicher"
],
"parentRequirements": [],
"childRequirements": [],
"implemented": [],
"prefix": "ENELIX",
"url": "https://git.belevo.ch/ENELIX/Enelix-EMS/src/branch/develop/Pufferspeicher"
}
+899
View File
@@ -0,0 +1,899 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../libs/VerbraucherSchnittstelle.php';
require_once __DIR__ . '/../libs/VerbraucherBasisTrait.php';
require_once __DIR__ . '/../libs/Nachrichtenvertrag.php';
require_once __DIR__ . '/../libs/PufferspeicherRegler.php';
use Belevo\EnelixEMS\Nachrichtenvertrag;
use Belevo\EnelixEMS\PufferspeicherRegler;
use Belevo\EnelixEMS\VerbraucherBasisTrait;
use Belevo\EnelixEMS\VerbraucherSchnittstelle;
class VerbraucherPufferspeicher extends IPSModule implements VerbraucherSchnittstelle
{
use VerbraucherBasisTrait;
private const MANAGER_MODULE_ID = '{6F771B18-59D4-4C8A-B951-3B2FE9F6A2C4}';
private const STATUS_AKTIV = 102;
private const STATUS_TEMPERATUR_UNGUELTIG = 201;
private const STATUS_KONFIGURATION_UNGUELTIG = 202;
private const STATUS_SCHALTFEHLER = 203;
private const VM_UPDATE = 10603;
/** @var list<string> */
private const DIAGNOSE_VARIABLEN = [
'Istleistung',
'Leistungsquelle',
'Sollleistung',
'SollwertGueltig',
'Verfuegbar',
'AenderungMoeglich',
'Stoerung',
'Stoertext',
'AktiveStufe',
'BezogeneEnergie',
'TemperaturenGueltig',
];
public function Create(): void
{
parent::Create();
$this->registriereVerbraucherBasis();
$this->RegisterPropertyString('LeistungsStufen', '[]');
$this->RegisterPropertyInteger('PufferfuehlerVariableID', 0);
$this->RegisterPropertyInteger('AussentemperaturVariableID', 0);
$this->RegisterPropertyInteger('WaermepumpenSolltemperaturVariableID', 0);
$this->RegisterPropertyFloat('FusspunktVorlauftemperatur', 35.0);
$this->RegisterPropertyFloat('HeizkurvenSteigung', 1.0);
$this->RegisterPropertyFloat('HeizkurveMinimaltemperatur', 20.0);
$this->RegisterPropertyFloat('HeizkurveMaximaltemperatur', 80.0);
$this->RegisterPropertyFloat('Hysterese', 5.0);
$this->RegisterPropertyInteger('MindesttemperaturModus', PufferspeicherRegler::MINIMUM_AUS);
$this->RegisterPropertyFloat('Mindesttemperatur', 20.0);
$this->RegisterPropertyFloat('MindesttemperaturDifferenz', 5.0);
$this->RegisterPropertyInteger('LastwechselSperrzeit', 5);
$this->RegisterPropertyInteger('TemperaturMaxAlter', 120);
$this->RegisterPropertyBoolean('PuffertemperaturGlaetten', false);
$this->RegisterPropertyInteger('ZeitKonstante', 120);
$this->RegisterPropertyBoolean('DiagnosevariablenAnzeigen', false);
$this->RegisterVariableFloat('Puffertemperatur', 'Puffertemperatur', '~Temperature', 100);
$this->RegisterVariableFloat('Aussentemperatur', 'Aussentemperatur', '~Temperature', 110);
$this->RegisterVariableFloat('Solltemperatur', 'Solltemperatur', '~Temperature', 120);
$this->RegisterVariableBoolean('Heizbedarf', 'Heizbedarf', '~Switch', 130);
$this->RegisterAttributeInteger('RegistriertePufferID', 0);
$this->RegisterAttributeInteger('RegistrierteAussenID', 0);
$this->RegisterAttributeInteger('LetzteBerechnung', 0);
$this->RegisterAttributeInteger('LetzteTemperaturberechnung', 0);
$this->RegisterAttributeInteger('LetzteVorgabeZeit', 0);
$this->RegisterAttributeInteger('LetzterLastwechsel', 0);
$this->RegisterAttributeInteger('LetzteManagerID', 0);
$this->RegisterAttributeFloat('Glaettungswert', 0.0);
$this->RegisterAttributeBoolean('GlaettungInitialisiert', false);
$this->RegisterAttributeString('Leistungsangebot', '[0]');
$this->RegisterAttributeString('Betriebsart', Nachrichtenvertrag::BETRIEBSART_PV);
$this->RegisterAttributeString('Schaltfehler', '');
$this->RegisterAttributeFloat('ZustandIstleistung', 0.0);
$this->RegisterAttributeInteger('ZustandSollleistung', 0);
$this->RegisterAttributeBoolean('ZustandSollwertGueltig', false);
$this->RegisterAttributeBoolean('ZustandVerfuegbar', false);
$this->RegisterAttributeBoolean('ZustandAenderungMoeglich', false);
$this->RegisterAttributeBoolean('ZustandStoerung', false);
$this->RegisterAttributeString('ZustandStoertext', '');
$this->RegisterAttributeInteger('ZustandAktiveStufe', 0);
$this->RegisterAttributeFloat('ZustandBezogeneEnergie', 0.0);
$this->RegisterAttributeBoolean('ZustandTemperaturenGueltig', false);
$this->RegisterTimer(
'LastwechselFreigabe',
0,
"IPS_RequestAction(\$_IPS['TARGET'], 'LastwechselFreigabe', false);"
);
$this->RegisterTimer(
'Meldezyklus',
0,
"IPS_RequestAction(\$_IPS['TARGET'], 'Melden', false);"
);
$this->RegisterTimer(
'RueckmeldungVerzoegert',
0,
"IPS_RequestAction(\$_IPS['TARGET'], 'Melden', true);"
);
}
public function ApplyChanges(): void
{
parent::ApplyChanges();
$this->aktualisiereVariablen();
try {
$this->pruefeKonfiguration();
} catch (Throwable $fehler) {
$this->deaktiviereTimer();
$this->setzeZustand('Verfuegbar', false);
$this->setzeZustand('AenderungMoeglich', false);
$this->setzeZustand('Stoerung', true);
$this->setzeZustand('Stoertext', $fehler->getMessage());
$this->SetStatus(self::STATUS_KONFIGURATION_UNGUELTIG);
$this->protokolliere('Konfiguration', $fehler->getMessage());
return;
}
$this->registriereTemperaturmeldungen();
$this->SetTimerInterval('LastwechselFreigabe', 0);
$this->SetTimerInterval('Meldezyklus', $this->ReadPropertyInteger('Meldeintervall') * 1000);
$this->regelzyklus(true);
}
public function MessageSink($zeitstempel, $senderID, $nachricht, $daten): void
{
if ((int) $nachricht !== self::VM_UPDATE) {
return;
}
if (in_array((int) $senderID, [
$this->ReadPropertyInteger('PufferfuehlerVariableID'),
$this->ReadPropertyInteger('AussentemperaturVariableID'),
], true)) {
$this->regelzyklus(true);
}
}
public function RequestAction($ident, $wert): void
{
switch ($ident) {
case 'Aktiv':
$this->SetValue('Aktiv', (bool) $wert);
if (!(bool) $wert) {
$this->setzeZustand('SollwertGueltig', false);
}
$this->regelzyklus(true);
return;
case 'HeizkurveVonWaermepumpeUebernehmen':
$this->uebernehmeWaermepumpenwert();
return;
case 'LastwechselFreigabe':
$this->SetTimerInterval('LastwechselFreigabe', 0);
$this->regelzyklus(true);
return;
case 'Melden':
if ((bool) $wert) {
$this->SetTimerInterval('RueckmeldungVerzoegert', 0);
}
$this->regelzyklus(false);
$this->sendeVerbraucherdaten();
return;
case 'ManagerdatenEmpfangen':
if (!is_string($wert)) {
throw new InvalidArgumentException('Managerdaten muessen als JSON uebergeben werden.');
}
$daten = json_decode($wert, true, 512, JSON_THROW_ON_ERROR);
if (!is_array($daten)) {
throw new InvalidArgumentException('Managerdaten muessen ein JSON-Objekt sein.');
}
$this->ManagerdatenEmpfangen($daten);
return;
}
throw new InvalidArgumentException('Unbekannte Aktion: ' . $ident);
}
/** @param array<string, mixed> $daten */
public function ManagerdatenEmpfangen(array $daten): void
{
Nachrichtenvertrag::pruefeManagerdaten($daten);
if ($daten['Kopf']['EmpfaengerID'] !== $this->InstanceID) {
throw new InvalidArgumentException('Managerdaten sind an eine andere Instanz adressiert.');
}
$managerID = $daten['Kopf']['AbsenderID'];
if (!in_array($managerID, $this->zugeordneteManagerIDs(), true)) {
throw new InvalidArgumentException('Der Manager hat diesen Pufferspeicher nicht aktiv zugeordnet.');
}
$this->WriteAttributeString('Betriebsart', $daten['Betriebsart']);
$this->regelzyklus(false);
$angebot = $this->leseLeistungsangebot();
$sollleistung = $daten['Sollleistung_W'];
if ($sollleistung === null) {
if ((bool) $this->leseZustand('SollwertGueltig')
&& !in_array((int) $this->leseZustand('Sollleistung'), $angebot, true)
) {
$this->setzeZustand('SollwertGueltig', false);
$this->regelzyklus(false);
}
$this->SetTimerInterval('RueckmeldungVerzoegert', 100);
return;
}
if (!in_array($sollleistung, $angebot, true)) {
throw new InvalidArgumentException('Sollleistung_W liegt nicht im aktuell gemeldeten Leistungsangebot.');
}
$this->WriteAttributeInteger('LetzteManagerID', $managerID);
$this->WriteAttributeInteger('LetzteVorgabeZeit', time());
$this->setzeZustand('Sollleistung', $sollleistung);
$this->setzeZustand('SollwertGueltig', true);
$this->regelzyklus(false);
$this->SetTimerInterval('RueckmeldungVerzoegert', 100);
}
private function regelzyklus(bool $meldungPlanen): void
{
try {
$jetzt = time();
$this->aktualisiereEnergie($jetzt);
$temperaturenGueltig = $this->aktualisiereTemperaturen();
$stufen = PufferspeicherRegler::dekodiereLeistungsstufen(
$this->ReadPropertyString('LeistungsStufen')
);
$solltemperatur = 0.0;
$einschaltschwelle = 0.0;
if ($temperaturenGueltig) {
$solltemperatur = PufferspeicherRegler::solltemperatur(
(float) $this->GetValue('Aussentemperatur'),
$this->ReadPropertyFloat('FusspunktVorlauftemperatur'),
$this->ReadPropertyFloat('HeizkurvenSteigung'),
$this->ReadPropertyFloat('HeizkurveMinimaltemperatur'),
$this->ReadPropertyFloat('HeizkurveMaximaltemperatur')
);
$einschaltschwelle = PufferspeicherRegler::einschaltschwelle(
$solltemperatur,
$this->ReadPropertyFloat('Hysterese'),
$this->ReadPropertyInteger('MindesttemperaturModus'),
$this->ReadPropertyFloat('Mindesttemperatur'),
$this->ReadPropertyFloat('MindesttemperaturDifferenz')
);
}
$this->SetValue('Solltemperatur', $solltemperatur);
$aktiv = (bool) $this->GetValue('Aktiv');
$peak = $this->ReadAttributeString('Betriebsart') === Nachrichtenvertrag::BETRIEBSART_PEAK;
$angebot = PufferspeicherRegler::leistungsangebot(
$aktiv,
$peak,
$temperaturenGueltig,
(float) $this->GetValue('Puffertemperatur'),
$einschaltschwelle,
$stufen
);
$heizbedarf = count($angebot) > 1;
$this->SetValue('Heizbedarf', $heizbedarf);
$verfuegbar = $aktiv && $temperaturenGueltig && $stufen !== [];
$aenderungMoeglich = $verfuegbar && count($angebot) > 1;
$zwangsleistung = (!$verfuegbar || !$heizbedarf) ? 0 : null;
$aktuelleLeistung = (int) round((float) $this->leseZustand('Istleistung'));
$restzeit = PufferspeicherRegler::restzeitBisLastwechsel(
$this->ReadAttributeInteger('LetzterLastwechsel'),
$jetzt,
$this->ReadPropertyInteger('LastwechselSperrzeit')
);
$lastwechselGesperrt = $restzeit > 0 && $verfuegbar && $zwangsleistung !== 0;
$this->SetTimerInterval('LastwechselFreigabe', $lastwechselGesperrt ? $restzeit * 1000 : 0);
if ($lastwechselGesperrt) {
$angebot = [$aktuelleLeistung];
$aenderungMoeglich = false;
}
$letzteVorgabe = $this->ReadAttributeInteger('LetzteVorgabeZeit');
if ($letzteVorgabe <= 0
|| $jetzt - $letzteVorgabe > $this->ReadPropertyInteger('VorgabeTimeout')
) {
$this->setzeZustand('SollwertGueltig', false);
}
$ziel = 0;
if ($lastwechselGesperrt) {
$ziel = $aktuelleLeistung;
} elseif ($zwangsleistung !== null) {
$ziel = $zwangsleistung;
} elseif ((bool) $this->leseZustand('SollwertGueltig')) {
$ziel = (int) $this->leseZustand('Sollleistung');
if (!in_array($ziel, $angebot, true)) {
$this->setzeZustand('SollwertGueltig', false);
$ziel = 0;
}
}
$lastwechsel = $this->schalteLeistung($ziel, $jetzt);
if ($lastwechsel) {
$angebot = [$ziel];
$aenderungMoeglich = false;
}
$this->WriteAttributeString('Leistungsangebot', json_encode($angebot, JSON_THROW_ON_ERROR));
$this->setzeZustand('Sollleistung', $ziel);
$this->setzeZustand('Verfuegbar', $verfuegbar);
$this->setzeZustand('AenderungMoeglich', $aenderungMoeglich);
$this->aktualisiereStoerungen($temperaturenGueltig);
if ($meldungPlanen) {
$this->SetTimerInterval('RueckmeldungVerzoegert', 100);
}
} catch (Throwable $fehler) {
$this->WriteAttributeString('Schaltfehler', $fehler->getMessage());
$this->setzeZustand('Verfuegbar', false);
$this->setzeZustand('AenderungMoeglich', false);
$this->setzeZustand('Stoerung', true);
$this->setzeZustand('Stoertext', $fehler->getMessage());
$this->SetStatus(self::STATUS_SCHALTFEHLER);
$this->protokolliere('Regelzyklus', $fehler->getMessage());
}
}
private function aktualisiereTemperaturen(): bool
{
$pufferID = $this->ReadPropertyInteger('PufferfuehlerVariableID');
$aussenID = $this->ReadPropertyInteger('AussentemperaturVariableID');
if (!$this->temperaturwertGueltig($pufferID) || !$this->temperaturwertGueltig($aussenID)) {
$this->setzeZustand('TemperaturenGueltig', false);
return false;
}
$puffertemperatur = (float) GetValue($pufferID);
$aussentemperatur = (float) GetValue($aussenID);
$jetzt = time();
$letzteBerechnung = $this->ReadAttributeInteger('LetzteTemperaturberechnung');
$this->WriteAttributeInteger('LetzteTemperaturberechnung', $jetzt);
if ($this->ReadPropertyBoolean('PuffertemperaturGlaetten')) {
if (!$this->ReadAttributeBoolean('GlaettungInitialisiert')) {
$this->WriteAttributeFloat('Glaettungswert', $puffertemperatur);
$this->WriteAttributeBoolean('GlaettungInitialisiert', true);
} elseif ($letzteBerechnung > 0 && $jetzt > $letzteBerechnung) {
$puffertemperatur = PufferspeicherRegler::pt1(
$this->ReadAttributeFloat('Glaettungswert'),
$puffertemperatur,
$jetzt - $letzteBerechnung,
$this->ReadPropertyInteger('ZeitKonstante')
);
$this->WriteAttributeFloat('Glaettungswert', $puffertemperatur);
} else {
$puffertemperatur = $this->ReadAttributeFloat('Glaettungswert');
}
} else {
$this->WriteAttributeBoolean('GlaettungInitialisiert', false);
}
$this->SetValue('Puffertemperatur', $puffertemperatur);
$this->SetValue('Aussentemperatur', $aussentemperatur);
$this->setzeZustand('TemperaturenGueltig', true);
return true;
}
private function temperaturwertGueltig(int $variablenID): bool
{
if ($variablenID <= 0 || !IPS_VariableExists($variablenID)) {
return false;
}
$variable = IPS_GetVariable($variablenID);
if (!in_array((int) $variable['VariableType'], [1, 2], true)) {
return false;
}
if (time() - (int) $variable['VariableUpdated'] > $this->ReadPropertyInteger('TemperaturMaxAlter')) {
return false;
}
$wert = GetValue($variablenID);
return is_int($wert) || is_float($wert);
}
private function uebernehmeWaermepumpenwert(): void
{
$wpID = $this->ReadPropertyInteger('WaermepumpenSolltemperaturVariableID');
$aussenID = $this->ReadPropertyInteger('AussentemperaturVariableID');
if (!$this->temperaturwertGueltig($wpID) || !$this->temperaturwertGueltig($aussenID)) {
throw new RuntimeException('Waermepumpen-Solltemperatur oder Aussentemperatur ist ungueltig.');
}
$fusspunkt = PufferspeicherRegler::fusspunktAusWaermepumpenwert(
(float) GetValue($wpID),
(float) GetValue($aussenID),
$this->ReadPropertyFloat('HeizkurvenSteigung')
);
IPS_SetProperty($this->InstanceID, 'FusspunktVorlauftemperatur', $fusspunkt);
IPS_ApplyChanges($this->InstanceID);
$this->protokolliere('Heizkurve', ['FusspunktVorlauftemperatur' => $fusspunkt]);
}
private function schalteLeistung(int $zielLeistung, int $jetzt): bool
{
$stufen = PufferspeicherRegler::dekodiereLeistungsstufen(
$this->ReadPropertyString('LeistungsStufen')
);
$zielStufe = PufferspeicherRegler::stufeFuerLeistung($stufen, $zielLeistung);
$bisher = (int) round((float) $this->leseZustand('Istleistung'));
if ($zielLeistung === $bisher && $this->kontakteEntsprechenZiel($stufen, $zielLeistung)) {
$this->WriteAttributeString('Schaltfehler', '');
return false;
}
try {
foreach ($stufen as $stufe) {
RequestAction($stufe['KontaktID'], false);
}
foreach ($stufen as $stufe) {
if ($stufe['Leistung_W'] === $zielLeistung && $zielLeistung > 0) {
RequestAction($stufe['KontaktID'], true);
break;
}
}
} catch (Throwable $fehler) {
foreach ($stufen as $stufe) {
try {
RequestAction($stufe['KontaktID'], false);
} catch (Throwable $ignoriert) {
}
}
$this->setzeZustand('Istleistung', 0.0);
$this->setzeZustand('AktiveStufe', 0);
throw new RuntimeException('Leistungsstufe konnte nicht sicher geschaltet werden: ' . $fehler->getMessage());
}
$this->WriteAttributeString('Schaltfehler', '');
$this->setzeZustand('Istleistung', (float) $zielLeistung);
$this->setzeZustand('Leistungsquelle', Nachrichtenvertrag::LEISTUNGSQUELLE_BERECHNET);
$this->setzeZustand('AktiveStufe', $zielStufe);
$this->WriteAttributeInteger('LetzterLastwechsel', $jetzt);
$this->protokolliere('Leistungsstufe', ['Leistung_W' => $zielLeistung, 'Stufe' => $zielStufe]);
return true;
}
/** @param list<array{Stufe: int, Leistung_W: int, KontaktID: int}> $stufen */
private function kontakteEntsprechenZiel(array $stufen, int $zielLeistung): bool
{
foreach ($stufen as $stufe) {
if (!IPS_VariableExists($stufe['KontaktID'])) {
return false;
}
$sollEin = $zielLeistung > 0 && $stufe['Leistung_W'] === $zielLeistung;
if ((bool) GetValue($stufe['KontaktID']) !== $sollEin) {
return false;
}
}
return true;
}
private function aktualisiereEnergie(int $jetzt): void
{
$letzteBerechnung = $this->ReadAttributeInteger('LetzteBerechnung');
$this->WriteAttributeInteger('LetzteBerechnung', $jetzt);
if ($letzteBerechnung <= 0 || $jetzt <= $letzteBerechnung) {
return;
}
$zuwachs = PufferspeicherRegler::energieKWh(
(float) $this->leseZustand('Istleistung'),
$jetzt - $letzteBerechnung
);
$this->setzeZustand(
'BezogeneEnergie',
(float) $this->leseZustand('BezogeneEnergie') + $zuwachs
);
}
private function aktualisiereStoerungen(bool $temperaturenGueltig): void
{
$stoerungen = [];
if (!$temperaturenGueltig) {
$stoerungen[] = 'Puffer- oder Aussentemperatur fehlt oder ist veraltet.';
}
if ($this->ReadAttributeString('Schaltfehler') !== '') {
$stoerungen[] = $this->ReadAttributeString('Schaltfehler');
}
$this->setzeZustand('Stoerung', $stoerungen !== []);
$this->setzeZustand('Stoertext', implode("
", array_values(array_unique($stoerungen))));
if ($this->ReadAttributeString('Schaltfehler') !== '') {
$this->SetStatus(self::STATUS_SCHALTFEHLER);
} elseif (!$temperaturenGueltig) {
$this->SetStatus(self::STATUS_TEMPERATUR_UNGUELTIG);
} else {
$this->SetStatus(self::STATUS_AKTIV);
}
}
private function sendeVerbraucherdaten(): void
{
foreach ($this->zugeordneteManagerIDs() as $managerID) {
$daten = $this->baueVerbraucherdaten($managerID);
Nachrichtenvertrag::pruefeVerbraucherdaten($daten);
try {
IPS_RequestAction(
$managerID,
'VerbraucherdatenEmpfangen',
json_encode($daten, JSON_THROW_ON_ERROR)
);
} catch (Throwable $fehler) {
$this->protokolliere('Managerkommunikation', $fehler->getMessage());
}
}
}
/** @return array<string, mixed> */
private function baueVerbraucherdaten(int $managerID): array
{
$temperaturenGueltig = (bool) $this->leseZustand('TemperaturenGueltig');
$schaltfehler = $this->ReadAttributeString('Schaltfehler');
return [
'Kopf' => [
'Version' => Nachrichtenvertrag::VERSION,
'AbsenderID' => $this->InstanceID,
'EmpfaengerID' => $managerID,
'Zeitpunkt' => time(),
],
'Betriebsart' => $this->ReadAttributeString('Betriebsart'),
'PrioritaetPV' => $this->ReadPropertyInteger('PrioritaetPV'),
'PrioritaetPeak' => $this->ReadPropertyInteger('PrioritaetPeak'),
'Leistungswerte_W' => $this->leseLeistungsangebot(),
'AenderungMoeglich' => (bool) $this->leseZustand('AenderungMoeglich'),
'Verfuegbar' => (bool) $this->leseZustand('Verfuegbar'),
'Istleistung_W' => (float) $this->leseZustand('Istleistung'),
'Leistungsquelle' => Nachrichtenvertrag::LEISTUNGSQUELLE_BERECHNET,
'Zustand' => [
[
'Kennung' => 'Sollleistung_W',
'Art' => 'Sollwert',
'Wert' => (bool) $this->leseZustand('SollwertGueltig')
? (int) $this->leseZustand('Sollleistung')
: null,
'Einheit' => 'W',
],
[
'Kennung' => 'Puffertemperatur_C',
'Art' => 'Istwert',
'Wert' => $temperaturenGueltig ? (float) $this->GetValue('Puffertemperatur') : null,
'Einheit' => 'C',
],
[
'Kennung' => 'Aussentemperatur_C',
'Art' => 'Istwert',
'Wert' => $temperaturenGueltig ? (float) $this->GetValue('Aussentemperatur') : null,
'Einheit' => 'C',
],
[
'Kennung' => 'Solltemperatur_C',
'Art' => 'Sollwert',
'Wert' => $temperaturenGueltig ? (float) $this->GetValue('Solltemperatur') : null,
'Einheit' => 'C',
],
[
'Kennung' => 'Heizbedarf',
'Art' => 'Status',
'Wert' => (bool) $this->GetValue('Heizbedarf'),
'Einheit' => '',
],
[
'Kennung' => 'AktiveStufe',
'Art' => 'Status',
'Wert' => (int) $this->leseZustand('AktiveStufe'),
'Einheit' => '',
],
[
'Kennung' => 'Fuehlerfehler',
'Art' => 'Stoerung',
'Wert' => !$temperaturenGueltig,
'Einheit' => '',
'Text' => $temperaturenGueltig
? ''
: 'Puffer- oder Aussentemperatur fehlt oder ist veraltet.',
],
[
'Kennung' => 'Schaltfehler',
'Art' => 'Stoerung',
'Wert' => $schaltfehler !== '',
'Einheit' => '',
'Text' => $schaltfehler,
],
],
];
}
/** @return list<int> */
private function zugeordneteManagerIDs(): array
{
$ergebnis = [];
foreach (IPS_GetInstanceListByModuleID(self::MANAGER_MODULE_ID) as $managerID) {
try {
$automatisch = (bool) IPS_GetProperty($managerID, 'AutomatischeSuche');
$property = $automatisch
? 'AutomatischeVerbraucherZuordnung'
: 'VerbraucherZuordnung';
$zuordnung = $this->leseManagerZuordnung($managerID, $property);
if ($automatisch && $zuordnung === []) {
$zuordnung = $this->leseManagerZuordnung($managerID, 'VerbraucherZuordnung');
}
} catch (Throwable $fehler) {
continue;
}
foreach ($zuordnung as $eintrag) {
if (!is_array($eintrag) || ($eintrag['Aktiv'] ?? false) !== true) {
continue;
}
$instanzID = $eintrag['InstanzID'] ?? $eintrag['Verbraucher'] ?? null;
if ($instanzID === $this->InstanceID) {
$ergebnis[] = (int) $managerID;
break;
}
}
}
return array_values(array_unique($ergebnis));
}
/** @return array<mixed> */
private function leseManagerZuordnung(int $managerID, string $property): array
{
$zuordnung = json_decode(
(string) IPS_GetProperty($managerID, $property),
true,
512,
JSON_THROW_ON_ERROR
);
return is_array($zuordnung) ? $zuordnung : [];
}
private function pruefeKonfiguration(): void
{
foreach (['PrioritaetPV', 'PrioritaetPeak'] as $property) {
if ($this->ReadPropertyInteger($property) < 0) {
throw new InvalidArgumentException($property . ' muss mindestens 0 sein.');
}
}
foreach ([
'Meldeintervall',
'VorgabeTimeout',
'TemperaturMaxAlter',
'ZeitKonstante',
] as $property) {
if ($this->ReadPropertyInteger($property) <= 0) {
throw new InvalidArgumentException($property . ' muss groesser als 0 sein.');
}
}
if ($this->ReadPropertyInteger('LastwechselSperrzeit') < 0) {
throw new InvalidArgumentException('LastwechselSperrzeit darf nicht negativ sein.');
}
if ($this->ReadPropertyFloat('Hysterese') <= 0.0) {
throw new InvalidArgumentException('Hysterese muss groesser als 0 sein.');
}
PufferspeicherRegler::solltemperatur(
20.0,
$this->ReadPropertyFloat('FusspunktVorlauftemperatur'),
$this->ReadPropertyFloat('HeizkurvenSteigung'),
$this->ReadPropertyFloat('HeizkurveMinimaltemperatur'),
$this->ReadPropertyFloat('HeizkurveMaximaltemperatur')
);
PufferspeicherRegler::einschaltschwelle(
50.0,
$this->ReadPropertyFloat('Hysterese'),
$this->ReadPropertyInteger('MindesttemperaturModus'),
$this->ReadPropertyFloat('Mindesttemperatur'),
$this->ReadPropertyFloat('MindesttemperaturDifferenz')
);
foreach (['PufferfuehlerVariableID', 'AussentemperaturVariableID'] as $property) {
$variablenID = $this->ReadPropertyInteger($property);
if ($variablenID <= 0 || !IPS_VariableExists($variablenID)) {
throw new InvalidArgumentException($property . ' ist nicht eingerichtet.');
}
$variable = IPS_GetVariable($variablenID);
if (!in_array((int) $variable['VariableType'], [1, 2], true)) {
throw new InvalidArgumentException($property . ' muss eine Integer- oder Floatvariable sein.');
}
}
$wpID = $this->ReadPropertyInteger('WaermepumpenSolltemperaturVariableID');
if ($wpID > 0) {
if (!IPS_VariableExists($wpID)) {
throw new InvalidArgumentException('WaermepumpenSolltemperaturVariableID existiert nicht.');
}
$wpVariable = IPS_GetVariable($wpID);
if (!in_array((int) $wpVariable['VariableType'], [1, 2], true)) {
throw new InvalidArgumentException(
'WaermepumpenSolltemperaturVariableID muss eine Integer- oder Floatvariable sein.'
);
}
}
$stufen = PufferspeicherRegler::dekodiereLeistungsstufen(
$this->ReadPropertyString('LeistungsStufen')
);
if ($stufen === []) {
throw new InvalidArgumentException('Mindestens eine Leistungsstufe ist erforderlich.');
}
foreach ($stufen as $stufe) {
if (!IPS_VariableExists($stufe['KontaktID'])) {
throw new InvalidArgumentException('Schaltkontakt ' . $stufe['KontaktID'] . ' existiert nicht.');
}
$kontakt = IPS_GetVariable($stufe['KontaktID']);
if ((int) $kontakt['VariableType'] !== 0) {
throw new InvalidArgumentException('Alle Schaltkontakte muessen Booleanvariablen sein.');
}
}
}
private function registriereTemperaturmeldungen(): void
{
$zuordnungen = [
'RegistriertePufferID' => $this->ReadPropertyInteger('PufferfuehlerVariableID'),
'RegistrierteAussenID' => $this->ReadPropertyInteger('AussentemperaturVariableID'),
];
foreach ($zuordnungen as $attribut => $neueID) {
$alteID = $this->ReadAttributeInteger($attribut);
if ($alteID > 0 && $alteID !== $neueID) {
$this->UnregisterMessage($alteID, self::VM_UPDATE);
}
if ($neueID > 0 && $neueID !== $alteID) {
$this->RegisterMessage($neueID, self::VM_UPDATE);
}
$this->WriteAttributeInteger($attribut, $neueID);
}
}
private function aktualisiereVariablen(): void
{
foreach (self::DIAGNOSE_VARIABLEN as $ident) {
if ($ident === 'Leistungsquelle') {
continue;
}
$variablenID = @IPS_GetObjectIDByIdent($ident, $this->InstanceID);
if (is_int($variablenID) && $variablenID > 0 && IPS_VariableExists($variablenID)) {
$this->setzeZustand($ident, GetValue($variablenID));
}
}
if ($this->ReadPropertyBoolean('DiagnosevariablenAnzeigen')) {
$this->registriereVerbraucherDiagnose();
$this->RegisterVariableInteger('AktiveStufe', 'Aktive Stufe', '', 200);
$this->RegisterVariableFloat('BezogeneEnergie', 'Bezogene Energie', '~Electricity', 210);
$this->RegisterVariableBoolean(
'TemperaturenGueltig',
'Temperaturen gueltig',
'~Switch',
220
);
foreach (self::DIAGNOSE_VARIABLEN as $ident) {
$wert = $ident === 'Leistungsquelle'
? Nachrichtenvertrag::LEISTUNGSQUELLE_BERECHNET
: $this->leseZustand($ident);
$this->setzeZustand($ident, $wert);
}
} else {
foreach (self::DIAGNOSE_VARIABLEN as $ident) {
$variablenID = @IPS_GetObjectIDByIdent($ident, $this->InstanceID);
if (is_int($variablenID) && $variablenID > 0 && IPS_VariableExists($variablenID)) {
$this->UnregisterVariable($ident);
}
}
}
}
/** @param bool|int|float|string $wert */
private function setzeZustand(string $ident, $wert): void
{
$booleanAttribute = [
'SollwertGueltig' => 'ZustandSollwertGueltig',
'Verfuegbar' => 'ZustandVerfuegbar',
'AenderungMoeglich' => 'ZustandAenderungMoeglich',
'Stoerung' => 'ZustandStoerung',
'TemperaturenGueltig' => 'ZustandTemperaturenGueltig',
];
$integerAttribute = [
'Sollleistung' => 'ZustandSollleistung',
'AktiveStufe' => 'ZustandAktiveStufe',
];
$floatAttribute = [
'Istleistung' => 'ZustandIstleistung',
'BezogeneEnergie' => 'ZustandBezogeneEnergie',
];
$stringAttribute = ['Stoertext' => 'ZustandStoertext'];
if (isset($booleanAttribute[$ident])) {
$wert = (bool) $wert;
$this->WriteAttributeBoolean($booleanAttribute[$ident], $wert);
} elseif (isset($integerAttribute[$ident])) {
$wert = (int) $wert;
$this->WriteAttributeInteger($integerAttribute[$ident], $wert);
} elseif (isset($floatAttribute[$ident])) {
$wert = (float) $wert;
$this->WriteAttributeFloat($floatAttribute[$ident], $wert);
} elseif (isset($stringAttribute[$ident])) {
$wert = (string) $wert;
$this->WriteAttributeString($stringAttribute[$ident], $wert);
} elseif ($ident !== 'Leistungsquelle') {
throw new LogicException('Unbekannter interner Zustand: ' . $ident);
}
$variablenID = @IPS_GetObjectIDByIdent($ident, $this->InstanceID);
if (is_int($variablenID) && $variablenID > 0 && IPS_VariableExists($variablenID)) {
SetValue($variablenID, $wert);
}
}
/** @return bool|int|float|string */
private function leseZustand(string $ident)
{
$booleanAttribute = [
'SollwertGueltig' => 'ZustandSollwertGueltig',
'Verfuegbar' => 'ZustandVerfuegbar',
'AenderungMoeglich' => 'ZustandAenderungMoeglich',
'Stoerung' => 'ZustandStoerung',
'TemperaturenGueltig' => 'ZustandTemperaturenGueltig',
];
$integerAttribute = [
'Sollleistung' => 'ZustandSollleistung',
'AktiveStufe' => 'ZustandAktiveStufe',
];
$floatAttribute = [
'Istleistung' => 'ZustandIstleistung',
'BezogeneEnergie' => 'ZustandBezogeneEnergie',
];
$stringAttribute = ['Stoertext' => 'ZustandStoertext'];
if (isset($booleanAttribute[$ident])) {
return $this->ReadAttributeBoolean($booleanAttribute[$ident]);
}
if (isset($integerAttribute[$ident])) {
return $this->ReadAttributeInteger($integerAttribute[$ident]);
}
if (isset($floatAttribute[$ident])) {
return $this->ReadAttributeFloat($floatAttribute[$ident]);
}
if (isset($stringAttribute[$ident])) {
return $this->ReadAttributeString($stringAttribute[$ident]);
}
throw new LogicException('Unbekannter interner Zustand: ' . $ident);
}
/** @return list<int> */
private function leseLeistungsangebot(): array
{
$angebot = json_decode(
$this->ReadAttributeString('Leistungsangebot'),
true,
512,
JSON_THROW_ON_ERROR
);
if (!is_array($angebot)) {
return [0];
}
return array_values(array_map('intval', $angebot));
}
private function deaktiviereTimer(): void
{
foreach (['LastwechselFreigabe', 'Meldezyklus', 'RueckmeldungVerzoegert'] as $timer) {
$this->SetTimerInterval($timer, 0);
}
}
/** @param mixed $daten */
private function protokolliere(string $bezeichnung, $daten): void
{
if (!$this->ReadPropertyBoolean('LoggingEin')) {
return;
}
$this->SendDebug(
$bezeichnung,
is_string($daten) ? $daten : json_encode($daten, JSON_THROW_ON_ERROR),
0
);
}
}
+5 -4
View File
@@ -5,9 +5,9 @@ Energiemanagement, Manager und steuerbare Verbraucher fuer IP-Symcon.
## Status
Das Repository befindet sich im Aufbau. Der gemeinsame Nachrichtenvertrag, die
Verbraucherbasis, der Manager, der mehrstufige Warmwassererwaermer und der
1-stufige Verbraucher sind implementiert. Die weiteren Module sind als
Diskussionsentwürfe dokumentiert.
Verbraucherbasis, der Manager, der mehrstufige Warmwassererwaermer, der
1-stufige Verbraucher und die Ladestation Stand-Alone sind implementiert. Die
weiteren Module sind als Diskussionsentwürfe dokumentiert.
## Geplante Module
@@ -17,7 +17,7 @@ Diskussionsentwürfe dokumentiert.
- Pufferspeicher
- Verbraucher 1-Stufig (implementiert)
- Waermepumpe
- Ladestation Stand-Alone
- Ladestation Stand-Alone (implementiert)
- Ladestation Gateway
- Easee Gateway
@@ -65,6 +65,7 @@ der Uebernahme in einen Freigabebranch erfolgreich sein.
- [Manager-Modul](docs/module/Manager/README.md)
- [Warmwassererwaermer-Modul](docs/module/Wassererwaermer/README.md)
- [Verbraucher-1-Stufig-Modul](docs/module/Verbraucher-1-Stufig/README.md)
- [Ladestation-Stand-Alone-Modul](docs/module/Ladestation-Stand-Alone/README.md)
- [Migration Boiler x-Stufig](docs/migration/Boiler-x-Stufig.md)
- [Migration Verbraucher 1-Stufig](docs/migration/Verbraucher-1-Stufig.md)
- [Obere Anschlüsse des Managers](docs/Obere-Anschluesse.md)
+5
View File
@@ -87,6 +87,11 @@
"suffix": " s",
"minimum": 0,
"maximum": 86400
},
{
"type": "CheckBox",
"name": "PeakSperreBeiMindestlaufzeitAnbieten",
"caption": "Bei Peak Sperre waehrend der Mindestlaufzeit anbieten"
}
]
},
+26 -3
View File
@@ -47,6 +47,7 @@ class VerbraucherEinStufig extends IPSModule implements VerbraucherSchnittstelle
$this->RegisterPropertyBoolean('SchaltkontaktInvertiert', false);
$this->RegisterPropertyInteger('RueckmeldungVariableID', 0);
$this->RegisterPropertyInteger('Mindestlaufzeit', 0);
$this->RegisterPropertyBoolean('PeakSperreBeiMindestlaufzeitAnbieten', true);
$this->RegisterPropertyInteger('Mindesteinschaltdauer', 5);
$this->RegisterPropertyInteger('Mindestausschaltdauer', 5);
$this->RegisterPropertyBoolean('DiagnosevariablenAnzeigen', false);
@@ -63,6 +64,7 @@ class VerbraucherEinStufig extends IPSModule implements VerbraucherSchnittstelle
$this->RegisterAttributeInteger('LaufzeitStandZeit', 0);
$this->RegisterAttributeString('LaufzeitTag', '');
$this->RegisterAttributeString('Leistungsangebot', '[0]');
$this->RegisterAttributeString('Betriebsart', Nachrichtenvertrag::BETRIEBSART_PV);
$this->RegisterAttributeBoolean('Initialisiert', false);
$this->RegisterAttributeBoolean('SchaltvorgangLaeuft', false);
$this->RegisterAttributeBoolean('SchaltpruefungAusstehend', false);
@@ -205,8 +207,18 @@ class VerbraucherEinStufig extends IPSModule implements VerbraucherSchnittstelle
throw new InvalidArgumentException('Der Manager hat diesen Verbraucher nicht aktiv zugeordnet.');
}
$this->WriteAttributeString('Betriebsart', $daten['Betriebsart']);
$this->aktualisiere(false);
$sollleistung = $daten['Sollleistung_W'];
if ($sollleistung === null) {
if ((bool) $this->leseZustand('SollwertGueltig')
&& !in_array((int) $this->leseZustand('Sollleistung'), $this->leseLeistungsangebot(), true)
) {
$this->setzeZustand('SollwertGueltig', false);
}
$this->planeMeldung();
return;
}
if (!in_array($sollleistung, $this->leseLeistungsangebot(), true)) {
throw new InvalidArgumentException(
'Sollleistung_W liegt nicht im aktuell gemeldeten Leistungsangebot.'
@@ -264,9 +276,16 @@ class VerbraucherEinStufig extends IPSModule implements VerbraucherSchnittstelle
$jetzt
);
$peakbetrieb = $this->ReadAttributeString('Betriebsart')
=== Nachrichtenvertrag::BETRIEBSART_PEAK;
$peakSperreBeiMindestlaufzeitAnbieten = $this->ReadPropertyBoolean(
'PeakSperreBeiMindestlaufzeitAnbieten'
);
$mindestlaufzeitLokalErzwingen = $mindestlaufzeitErzwungen
&& (!$peakbetrieb || !$peakSperreBeiMindestlaufzeitAnbieten);
$ziel = 0;
if ($verfuegbar) {
if ($mindestlaufzeitErzwungen) {
if ($mindestlaufzeitLokalErzwingen) {
$ziel = $this->ReadPropertyInteger('Nennleistung');
} elseif ((bool) $this->leseZustand('SollwertGueltig')) {
$ziel = (int) $this->leseZustand('Sollleistung');
@@ -302,7 +321,8 @@ class VerbraucherEinStufig extends IPSModule implements VerbraucherSchnittstelle
$this->setzeZustand('SchaltbefehlAusstehend', $wartetAufRueckmeldung);
$this->setzeZustand('RestMindestzeit', $restMindestzeit);
$aenderungMoeglich = $verfuegbar
&& !$mindestlaufzeitErzwungen
&& (!$mindestlaufzeitErzwungen
|| ($peakbetrieb && $peakSperreBeiMindestlaufzeitAnbieten))
&& $restMindestzeit === 0
&& !$wartetAufRueckmeldung;
$this->setzeZustand('AenderungMoeglich', $aenderungMoeglich);
@@ -312,7 +332,9 @@ class VerbraucherEinStufig extends IPSModule implements VerbraucherSchnittstelle
$istleistung,
$verfuegbar,
$aenderungMoeglich,
$mindestlaufzeitErzwungen
$mindestlaufzeitErzwungen,
$peakbetrieb,
$peakSperreBeiMindestlaufzeitAnbieten
);
$this->WriteAttributeString('Leistungsangebot', json_encode($angebot, JSON_THROW_ON_ERROR));
$this->aktualisiereStoerung();
@@ -599,6 +621,7 @@ class VerbraucherEinStufig extends IPSModule implements VerbraucherSchnittstelle
'EmpfaengerID' => $managerID,
'Zeitpunkt' => time(),
],
'Betriebsart' => $this->ReadAttributeString('Betriebsart'),
'PrioritaetPV' => $this->ReadPropertyInteger('PrioritaetPV'),
'PrioritaetPeak' => $this->ReadPropertyInteger('PrioritaetPeak'),
'Leistungswerte_W' => $this->leseLeistungsangebot(),
+40 -25
View File
@@ -79,6 +79,7 @@ class VerbraucherWarmwassererwaermer extends IPSModule implements VerbraucherSch
$this->RegisterAttributeFloat('Glaettungswert', 0.0);
$this->RegisterAttributeBoolean('GlaettungInitialisiert', false);
$this->RegisterAttributeString('Leistungsangebot', '[]');
$this->RegisterAttributeString('Betriebsart', Nachrichtenvertrag::BETRIEBSART_PV);
$this->RegisterAttributeString('Schaltfehler', '');
$this->RegisterAttributeFloat('ZustandIstleistung', 0.0);
$this->RegisterAttributeInteger('ZustandSollleistung', 0);
@@ -208,9 +209,20 @@ class VerbraucherWarmwassererwaermer extends IPSModule implements VerbraucherSch
throw new InvalidArgumentException('Der Manager hat diesen Verbraucher nicht aktiv zugeordnet.');
}
$this->WriteAttributeString('Betriebsart', $daten['Betriebsart']);
$this->regelzyklus(false);
$angebot = $this->leseLeistungsangebot();
$sollleistung = $daten['Sollleistung_W'];
if ($sollleistung === null) {
if ((bool) $this->leseZustand('SollwertGueltig')
&& !in_array((int) $this->leseZustand('Sollleistung'), $angebot, true)
) {
$this->setzeZustand('SollwertGueltig', false);
$this->regelzyklus(false);
}
$this->SetTimerInterval('RueckmeldungVerzoegert', 100);
return;
}
if (!in_array($sollleistung, $angebot, true)) {
throw new InvalidArgumentException('Sollleistung_W liegt nicht im aktuell gemeldeten Leistungsangebot.');
}
@@ -363,45 +375,47 @@ class VerbraucherWarmwassererwaermer extends IPSModule implements VerbraucherSch
(float) $this->ReadAttributeFloat('ZustandLegionellentemperatur')
);
$hysterese = $this->ReadPropertyFloat('Hysterese');
$maximaltemperaturErreicht = $this->ReadAttributeBoolean('MaximaltemperaturErreicht');
if ($temperatur >= $maximum) {
$maximaltemperaturErreicht = true;
} elseif ($temperatur <= $maximum - $hysterese) {
$maximaltemperaturErreicht = false;
}
$this->WriteAttributeBoolean('MaximaltemperaturErreicht', $maximaltemperaturErreicht);
$nachladen = (bool) $this->leseZustand('NachladenAktiv');
if ($temperatur < $minimum) {
$nachladen = true;
} elseif ($temperatur >= $minimum + $hysterese) {
$nachladen = false;
}
$this->setzeZustand('NachladenAktiv', $nachladen);
$zeitplanErfordertHeizen = false;
$naechster = WarmwasserRegler::naechsterZeitplaneintrag(
$this->ReadPropertyString('Zeitplan'),
new DateTimeImmutable('now')
);
if ($naechster !== null && $temperatur < $naechster['Solltemperatur']) {
$zeitplanErfordertHeizen = !WarmwasserRegler::kannZielErreichen(
if ($naechster !== null
&& $temperatur < $naechster['Solltemperatur']
&& !WarmwasserRegler::kannZielErreichen(
(float) $this->ReadPropertyInteger('Boilervolumen'),
$temperatur,
$naechster['Solltemperatur'],
max(0, $naechster['Zeitpunkt'] - $jetzt),
$maximalleistung
);
)
) {
$minimum = $naechster['Solltemperatur'];
}
if ($nachladen || $zeitplanErfordertHeizen) {
return [[$maximalleistung], false, true, $maximalleistung];
$stufeAktiv = (float) $this->leseZustand('Istleistung') > 0.0;
$nachladen = $temperatur < $minimum
|| ($temperatur < $minimum + $hysterese && $stufeAktiv);
$this->setzeZustand('NachladenAktiv', $nachladen);
$peakbetrieb = $this->ReadAttributeString('Betriebsart')
=== Nachrichtenvertrag::BETRIEBSART_PEAK;
$angebot = WarmwasserRegler::leistungsangebot(
$peakbetrieb,
$temperatur,
$minimum,
$maximum,
$hysterese,
$stufeAktiv,
$alleLeistungen
);
if ($angebot === [0]) {
return [$angebot, false, true, 0];
}
if ($maximaltemperaturErreicht) {
return [[0], false, true, 0];
if ($angebot === [$maximalleistung]) {
return [$angebot, false, true, $maximalleistung];
}
return [$alleLeistungen, true, true, null];
return [$angebot, true, true, null];
}
private function aktualisiereLegionellenstatus(bool $temperaturGueltig, int $jetzt): void
@@ -587,6 +601,7 @@ class VerbraucherWarmwassererwaermer extends IPSModule implements VerbraucherSch
'EmpfaengerID' => $managerID,
'Zeitpunkt' => time(),
],
'Betriebsart' => $this->ReadAttributeString('Betriebsart'),
'PrioritaetPV' => $this->ReadPropertyInteger('PrioritaetPV'),
'PrioritaetPeak' => $this->ReadPropertyInteger('PrioritaetPeak'),
'Leistungswerte_W' => $this->leseLeistungsangebot(),
+66 -65
View File
@@ -1,6 +1,6 @@
# EMS-Schnittstelle
Vertragsversion: `3.0`
Vertragsversion: `4.0`
Es gibt genau eine fachliche Empfangsmethode je Richtung:
@@ -13,7 +13,7 @@ public function VerbraucherdatenEmpfangen(array $daten): void;
| Feld | Typ | Bedeutung |
| --- | --- | --- |
| `Version` | Text | Vertragsversion `3.0` |
| `Version` | Text | Vertragsversion `4.0` |
| `AbsenderID` | Ganzzahl | Sendende Symcon-Instanz |
| `EmpfaengerID` | Ganzzahl | Empfangende Symcon-Instanz |
| `Zeitpunkt` | Ganzzahl | Unixzeit in UTC |
@@ -23,118 +23,119 @@ public function VerbraucherdatenEmpfangen(array $daten): void;
```json
{
"Kopf": {
"Version": "3.0",
"Version": "4.0",
"AbsenderID": 10001,
"EmpfaengerID": 20001,
"Zeitpunkt": 1788825600
},
"Betriebsart": "PV",
"Sollleistung_W": 1501
}
```
`Sollleistung_W` ist immer eine Ganzzahl und muss im aktuell gemeldeten Leistungsangebot liegen.
`Betriebsart` ist `PV` oder `Peak`. `Sollleistung_W` ist eine Ganzzahl
aus dem fuer diese Betriebsart gemeldeten Leistungsangebot oder `null`.
`null` kuendigt nur die Betriebsart an. Der Verbraucher uebernimmt sie,
berechnet sein Leistungsangebot neu und meldet es zurueck. Eine vorhandene
Sollleistung wird dabei nur verworfen, wenn sie im neuen Angebot nicht mehr
zulaessig ist.
## Verbraucher an Manager
```json
{
"Kopf": {
"Version": "3.0",
"Version": "4.0",
"AbsenderID": 20001,
"EmpfaengerID": 10001,
"Zeitpunkt": 1788825602
},
"Betriebsart": "Peak",
"PrioritaetPV": 0,
"PrioritaetPeak": 0,
"Leistungswerte_W": [
-3000,
-2000,
{"Von_W": -1000, "Bis_W": -500},
0,
100,
{"Von_W": 1000, "Bis_W": 2000},
3000
],
"AenderungMoeglich": true,
"Leistungswerte_W": [0],
"AenderungMoeglich": false,
"Verfuegbar": true,
"Istleistung_W": 1498.5,
"Leistungsquelle": 2,
"Istleistung_W": 0,
"Leistungsquelle": 1,
"Zustand": [
{
"Kennung": "Sollleistung_W",
"Art": "Sollwert",
"Wert": 1501,
"Wert": 0,
"Einheit": "W"
}
]
}
```
### Feste Regeln
## Betriebsart-Synchronisation
- Prioritaeten beginnen bei 0; eine kleinere Zahl bedeutet eine hoehere Prioritaet.
1. Der Manager bestimmt `PV` oder `Peak`.
2. Meldungen einer anderen Betriebsart werden nicht zur Verteilung verwendet.
3. Der Manager sendet diesen Verbrauchern eine Betriebsart-Ankuendigung mit
`Sollleistung_W=null`.
4. Jeder Verbraucher berechnet und meldet seine PowerSteps fuer diese
Betriebsart.
5. Erst wenn alle aktiven Verbraucher synchronisiert sind, verteilt der Manager
Sollleistungen.
Damit kann jeder Verbrauchertyp unterschiedliche Angebote fuer PV und Peak
melden, ohne dass der Manager seine interne Geraetelogik kennen muss.
## Feste Regeln
- Prioritaeten beginnen bei 0; eine kleinere Zahl bedeutet hoehere Prioritaet.
- `Leistungsquelle`: 0 nicht vorhanden, 1 berechnet, 2 gemessen.
- Bei Leistungsquelle 0 ist `Istleistung_W` zwingend `null`.
- Leistungsbereiche enthalten jeden ganzen Wattwert von `Von_W` bis `Bis_W` einschliesslich.
- Die Leistungswerte sind aufsteigend, eindeutig und ueberschneiden sich nicht.
- `Zustand` enthaelt immer den gemeinsamen Eintrag `Sollleistung_W` und daneben nur benoetigte geraetespezifische Eintraege.
- Eine aktive Stoerung wird mit `Art=Stoerung` und `Wert=true` gemeldet; eine behobene mit `false`.
- Verbraucher werden ausschliesslich im Manager zugeordnet. Der Verbraucher besitzt keine Manager-ID-Property.
- Leistungsbereiche enthalten jeden ganzen Wattwert von `Von_W` bis `Bis_W`.
- Leistungswerte sind aufsteigend, eindeutig und ueberschneiden sich nicht.
- `Zustand` enthaelt immer `Sollleistung_W`.
- Verbraucher werden ausschliesslich im Manager zugeordnet.
- Der Verbraucher besitzt keine Manager-ID-Property.
## Technische Umsetzung in IP-Symcon
Der Transport erfolgt über `IPS_RequestAction` mit JSON. `MessageSink` erkennt
registrierte Änderungen; der empfangende Action-Handler prüft das Paket und
ruft danach genau eine fachliche Empfangsmethode auf. Empfang und Neuberechnung
werden intern entkoppelt, damit keine gegenseitige Endlosschleife entsteht.
Der Transport erfolgt ueber `IPS_RequestAction` mit JSON. `MessageSink`
erkennt registrierte Aenderungen. Empfang und Neuberechnung sind intern
entkoppelt, damit keine gegenseitige Endlosschleife entsteht.
Das PHP-Interface legt nur die Methode fest. Die folgenden gemeinsamen
Symcon-Datenpunkte werden durch `VerbraucherBasisTrait` registriert.
Das PHP-Interface legt nur die Empfangsmethode fest. Die gemeinsamen
Symcon-Datenpunkte registriert `VerbraucherBasisTrait`.
### Gemeinsame Properties aller Verbraucher
| Ident | Typ | Standard | Beschreibung |
| --- | --- | --- | --- |
| `PrioritaetPV` | Integer | `0` | Priorität ab 0; kleinere Zahl bedeutet höhere Priorität. |
| `PrioritaetPeak` | Integer | `0` | Wie PV-Priorität, ohne fachliche Obergrenze. |
| `Meldeintervall` | Integer | `10` | Vollständige Rückmeldung in Sekunden; muss grösser als 0 sein. |
| `VorgabeTimeout` | Integer | `120` | Sollwert wird nach dieser Zeit ohne Erneuerung ungültig. |
| `EinstellungenInVisu` | Boolean | `false` | Blendet lokale Einstellvariablen ein und macht sie geprüft bedienbar. |
| `LoggingEin` | Boolean | `false` | Aktiviert das laufende Diagnoseprotokoll. Module können zusätzliche Diagnosevariablen getrennt einblenden. |
| `PrioritaetPV` | Integer | `0` | Prioritaet in der Betriebsart PV |
| `PrioritaetPeak` | Integer | `0` | Prioritaet in der Betriebsart Peak |
| `Meldeintervall` | Integer | `10` | Vollstaendige Rueckmeldung in Sekunden |
| `VorgabeTimeout` | Integer | `120` | Ablaufzeit einer Sollleistung |
| `EinstellungenInVisu` | Boolean | `false` | Lokale Einstellungen in der Visualisierung |
| `LoggingEin` | Boolean | `false` | Laufendes Diagnoseprotokoll |
### Gemeinsame Variablen aller Verbraucher
| Ident | Typ / Zugriff | Beschreibung |
| --- | --- | --- |
| `Aktiv` | Boolean / bedienbar | Lokale EMS-Freigabe; Start `false`. |
| `Istleistung` | Float / Anzeige | Aktuelle Leistung in W; nur zusammen mit `Leistungsquelle` gültig. |
| `Leistungsquelle` | Integer / Anzeige | `0` nicht vorhanden, `1` berechnet, `2` gemessen. |
| `Sollleistung` | Integer / Anzeige | Tatsächlich angenommene Vorgabe in W. |
| `SollwertGueltig` | Boolean / Anzeige | Kennzeichnet eine aktuell gültige Vorgabe. |
| `Verfuegbar` | Boolean / Anzeige | Verbraucher grundsätzlich für EMS-Steuerung verfügbar. |
| `AenderungMoeglich` | Boolean / Anzeige | Neue Vorgabe darf momentan übernommen werden. |
| `Stoerung` | Boolean / Anzeige | Mindestens ein Zustandseintrag der Art `Stoerung` ist aktiv. |
| `Stoertext` | String / Anzeige | Zusammengefasste lesbare Störbeschreibung. |
| `Aktiv` | Boolean / bedienbar | Lokale EMS-Freigabe; Start `false` |
| `Istleistung` | Float / Anzeige | Aktuelle Leistung in W |
| `Leistungsquelle` | Integer / Anzeige | 0 nicht vorhanden, 1 berechnet, 2 gemessen |
| `Sollleistung` | Integer / Anzeige | Angenommene oder lokal erzwungene Vorgabe |
| `SollwertGueltig` | Boolean / Anzeige | Aktuelle, nicht abgelaufene Vorgabe |
| `Verfuegbar` | Boolean / Anzeige | Verbraucher grundsaetzlich verfuegbar |
| `AenderungMoeglich` | Boolean / Anzeige | Neue Vorgabe darf uebernommen werden |
| `Stoerung` | Boolean / Anzeige | Mindestens eine Stoerung aktiv |
| `Stoertext` | String / Anzeige | Zusammengefasste Stoerbeschreibung |
`Leistungswerte_W` und `Zustand` werden intern gehalten und direkt in die
Nachricht geschrieben. Dafür werden keine zusätzlichen Symcon-Variablen
angelegt. Der gemeinsame Zustandseintrag `Sollleistung_W` wird durch die Basis
ergänzt. Während einer Lastwechselsperre meldet ein Verbraucher
`AenderungMoeglich` als `false` und beschränkt `Leistungswerte_W` auf die aktuell
gehaltene Leistung. Separate Felder wie `Idle` oder `IdleCounter` sind nicht
Bestandteil des Vertrags.
`Leistungswerte_W`, `Betriebsart` und `Zustand` werden intern gehalten
und direkt in die Nachricht geschrieben.
## Zeitverhalten
- Rückmeldung nach dem Start, bei relevanten Änderungen und zusätzlich alle
`Meldeintervall` Sekunden.
- Laufende Vorgaben werden vom Manager standardmässig alle 60 Sekunden erneuert.
- Nach `VorgabeTimeout` Sekunden ist eine nicht erneuerte Vorgabe ungültig.
- Nach einem Neustart wird zuerst der Gerätezustand erfasst; eine alte Vorgabe
wird nicht ungeprüft wieder aufgenommen.
## Obere Anschlüsse des Managers
SDL/VGT, Prognose, Lizenzierung und Störüberwachung verändern den
Manager-Verbraucher-Vertrag nicht. Ihr aktueller Diskussionsstand ist in
[Obere-Anschluesse.md](Obere-Anschluesse.md) beschrieben.
- Rueckmeldung nach Start, relevanten Aenderungen und alle `Meldeintervall`
Sekunden.
- Laufende Vorgaben werden vom Manager standardmaessig erneuert.
- Nach `VorgabeTimeout` ist eine nicht erneuerte Vorgabe ungueltig.
- Nach einem Neustart wird keine alte Vorgabe ungeprueft aufgenommen.
@@ -0,0 +1,76 @@
# ADR 0004: Betriebsartabhaengige Leistungsangebote
## Kontext
Der Nachrichtenvertrag 3.0 uebermittelte vom Manager nur
`Sollleistung_W`. Verbraucher meldeten ein einziges Leistungsangebot und
kannten die aktuelle Betriebsart nicht. Damit konnten Module keine
unterschiedlichen PowerSteps fuer PV- und Peakbetrieb bereitstellen.
Eine einfache Ergaenzung des Sollwertpakets reicht nicht aus: Beim Wechsel der
Betriebsart besitzt der Manager zunaechst noch das Angebot der vorherigen
Betriebsart. Eine sofortige Verteilung koennte deshalb einen Sollwert erzeugen,
den der Verbraucher im neuen Modus ablehnen muss.
## Entscheidung
Der Vertrag wird inkompatibel auf Version `4.0` angehoben.
- Managerdaten und Verbraucherdaten enthalten verpflichtend `Betriebsart`
mit dem Wert `PV` oder `Peak`.
- `Sollleistung_W` in Managerdaten darf `null` sein.
- `null` ist eine reine Betriebsart-Ankuendigung und kein Schaltbefehl.
- Der Verbraucher uebernimmt die Betriebsart, berechnet sein Leistungsangebot
neu und meldet dieses mit derselben Betriebsart zurueck.
- Der Manager verwendet nur Angebote seiner aktuellen Betriebsart.
- Solange mindestens ein aktiver Verbraucher nicht synchronisiert ist, erfolgt
keine Verteilung.
- Nach einem Betriebsartwechsel verwirft der Manager seinen Sollwertcache.
Die konkrete Differenz zwischen PV- und Peakangebot bleibt Verantwortung des
Verbrauchermoduls. Die derzeit implementierten Verbraucher uebernehmen die
zustandsabhaengigen Enelix-1-Angebote gezielt:
- Der einstufige Verbraucher bietet in Peak normalerweise `[0]`. Bei faelliger
Tagesmindestlaufzeit ist konfigurierbar, ob `[0, Nennleistung]` angeboten
oder nur die Nennleistung erzwungen wird.
- Die Ladestation bietet in Peak mit Solarladen `[0]`, ohne Solarladen dagegen
`[0, ...Ladestufen]` an.
- Der Warmwassererwaermer bietet unter seiner wirksamen Mindesttemperatur auch
in Peak `[0, ...Leistungsstufen]` an.
- Der Pufferspeicher bietet im Peakbetrieb unabhaengig vom Zustand `[0]` an.
- Technische Schaltsperren und ausstehende Rueckmeldungen duerfen das Angebot
weiterhin auf die aktuell gehaltene Leistung begrenzen.
## Alternativen
### Zwei Angebote gleichzeitig melden
Separate Felder fuer PV- und Peak-PowerSteps wuerden den Umschalt-Handshake
vermeiden. Sie verdoppeln jedoch alle Angebotsdaten und zwingen jedes Modul,
beide Zustaende jederzeit parallel zu berechnen.
### Betriebsart ohne Synchronisation senden
Der Manager koennte Betriebsart und Sollleistung in einem Paket senden. Das
erste Kommando nach einem Wechsel waere dann aus dem alten Angebot berechnet
und koennte ungueltig sein.
### Betriebsart aus einer Manager-Variable lesen
Eine direkte Objektkopplung wuerde die definierte Schnittstelle umgehen,
mehrere Manager erschweren und Verbraucher unnoetig an die Managerinstanz
binden.
## Folgen
- Alle Manager und Verbraucher einer Installation muessen gemeinsam auf
Vertrag 4.0 aktualisiert werden.
- Version 3.0 und 4.0 koennen nicht innerhalb derselben Managerzuordnung
gemischt werden.
- Ein Betriebsartwechsel benoetigt mindestens einen zusaetzlichen
Nachrichtenumlauf.
- Tests pruefen Vertragsvalidierung, Synchronisation und modulspezifische
Angebote fuer PV und Peak.
- ADR 0001 bleibt als historische Entscheidung erhalten, wird fuer den
aktuellen Vertrag aber durch diese Entscheidung abgeloest.
+51 -34
View File
@@ -1,46 +1,63 @@
# Ladestation Stand-Alone
> Status: Diskussionsentwurf. Eigenständiges Verbrauchermodul mit direkter
> Geräteanbindung; keine Beziehung zur Ladestation Gateway.
> Status: Implementiert. Eigenstaendiges Verbrauchermodul mit direkter
> Geraeteanbindung; keine Beziehung zur Ladestation Gateway.
## Zusätzliche Variablen
## Unterstuetzte Geraete
| Geraetetyp | Status | Steuerung |
| --- | --- | --- |
| go-e Charger, alte API | `GET /mqtt?payload=` | `alw` und `amp` |
| go-e Charger Gemini / Gemini flex | `GET /api/status` | `frc` und `amp` |
| smart-me Pico | Pico-Charging-API | Load-Management-Current-API mit Basic Auth |
Die Routen, Leistungsfaktoren und Erkennung wurden gezielt aus Enelix 1
uebernommen. Zugangsdaten werden weder als Variable noch im Diagnoseprotokoll
ausgegeben.
## Verhalten
- `FahrzeugVerbunden` entspricht bei go-e `car != 1` und bei Pico `State != 1`.
- Die Phasenerkennung verwendet wie Enelix 1 die gemessene Ladeleistung:
ueber 7500 W sind dreiphasig, sonst einphasig.
- Ein bereits verbundenes Fahrzeug gilt bei einem ermittelten Maximalstrom
unter 6 A als geladen.
- PV mit Solarladen bietet `0 W` und alle Ladestufen von 6 A bis zum
konfigurierten Maximum an.
- PV ohne Solarladen bietet ausschliesslich die Maximalleistung an.
- Peak mit Solarladen bietet nur `[0]` an und stoppt die Ladestation.
- Peak ohne Solarladen bietet `[0, ...Ladestufen]` an, damit der Manager die
Ladeleistung wie in Enelix 1 stufenweise reduzieren kann.
- Ohne gueltige Manager-Vorgabe wird bei Solarladen mit 0 A und ohne
Solarladen mit maximaler Leistung gefahren.
## Variablen
| Ident | Typ / Zugriff | Beschreibung |
| --- | --- | --- |
| `FahrzeugVerbunden` | Boolean / Anzeige | Nur bei gültigem Gerätestatus aussagekräftig. |
| `Fahrzeugstatus` | Integer / Anzeige | `0` unbekannt, `1` nicht verbunden, `2` bereit, `3` lädt, `4` voll, `5` Fehler. |
| `Ladestrom` | Float / Anzeige | Aktueller Ladestrom in A. |
| `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 / lokal bedienbar | Lokale Ladeerlaubnis zusätzlich zu `Aktiv`. |
| `Solarladen` | Boolean / lokal bedienbar | Überschussorientiertes Laden ein/aus. |
| `Ladefreigabe` | Boolean / bedienbar | Nur bei `EinstellungenInVisu`; lokale Ladeerlaubnis. |
| `Solarladen` | Boolean / bedienbar | Nur bei `EinstellungenInVisu`; variable Ladestufen ein/aus. |
## Zusätzliche Properties
## Properties
| Ident | Typ | Standard / Beschreibung |
| --- | --- | --- |
| `Ladefreigabe` | Boolean | `false`. |
| `Solarladen` | Boolean | `true`. |
| `MaximalerLadestrom` | Float | `0*` A; Installations- und Gerätegrenze. |
| `Ladestromschritt` | Float | `1` A; muss vom Gerät unterstützt sein. |
| `MindestEinzeit` | Integer | `0` s. |
| `MindestAuszeit` | Integer | `0` s. |
| `Geraetetyp` | Auswahl | `Unkonfiguriert`, `GoEAlt`, `GoEGemini` oder `SmartMePico`. |
| `Geraeteadresse` | String | leer; nur für lokale Adapter. |
| `GeraeteID` | String | leer; nur falls der Adapter eine ID benötigt. |
| `Seriennummer` | String | leer; nur falls benötigt. |
| `Benutzername` | String | leer; nur falls Anmeldung benötigt. |
| `Passwort` | String | leer; vertraulich und nie als Variable/Diagnose anzeigen. |
Neben den gemeinsamen Verbraucher-Properties werden Geraetetyp, Adresse,
Pico-ID, Seriennummer, Zugangsdaten, maximaler Ladestrom, Abfrageintervall
sowie die Startwerte fuer Ladefreigabe und Solarladen konfiguriert.
Minimaler Ladestrom, frühere Umschaltabstände, Benutzervariable und externe
Sollwert-Variable entfallen. Ladefreigabe und Solarladen erscheinen nur bei
aktivem `EinstellungenInVisu` als bedienbare Variablen.
Die gemeinsamen Verbraucher- und Diagnosevariablen entsprechen der
EMS-Schnittstelle Version 4.0. `LoggingEin` steuert ausschliesslich das
laufende Debug-Protokoll.
## Zustand
## Tests
`Fahrzeugstatus`, `FahrzeugVerbunden`, `Ladefreigabe`, `Solarladen` und
`Ladestrom_A` sowie `Ladefehler` und gerätespezifische Fehler.
## Offene Punkte
- Direkte Adapter und ihre noch gültigen API-Routen bei der Implementierung prüfen.
- Lokalen Mindestladebedarf bei deaktiviertem Solarladen genau festlegen.
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.
+130 -40
View File
@@ -1,54 +1,144 @@
# Pufferspeicher
# Verbraucher Pufferspeicher
> Status: Diskussionsentwurf.
> Status: implementiert fuer IP-Symcon 8.0+ und den Enelix-2-Vertrag `4.0`.
Regelt elektrische Heizstufen nach Pufferbedarf. Die Heizkurve verwendet einen
Fusspunkt bei 20 °C Aussentemperatur und eine Steigung:
Das Modul bindet elektrische Heizstufen eines Pufferspeichers an den
Enelix-Manager an. Die Freigabe wird aus Puffer- und Aussentemperatur,
Heizkurve, Hysterese, lokaler Aktivierung und aktueller Betriebsart berechnet.
`Solltemperatur = FusspunktVorlauftemperatur + HeizkurvenSteigung × (20 - Aussentemperatur)`
Es ist eine gezielte Adaption des Enelix-1-Moduls `Puffer_Speicher`.
Leistungsstufen, Temperaturquellen, optionale PT1-Glaettung und Heizkurve
wurden uebernommen und ueberarbeitet. Zyklische Altlogik, alte
Kommunikationsvariablen, `Idle` und `PowerSteps` als Symcon-Variable wurden
verworfen.
Anschliessend wird auf Mindest- und Maximaltemperatur begrenzt.
| Technisches Merkmal | Wert |
| --- | --- |
| Modulname | `VerbraucherPufferspeicher` |
| Alias | `Pufferspeicher` |
| Modul-ID | `{C92D5EEF-9632-47A5-9659-4B02BF40FBE9}` |
| Enelix-Vertrag | `4.0` |
## Zusätzliche Variablen
## Regelverhalten
| Ident | Typ / Zugriff | Beschreibung |
Die Solltemperatur wird berechnet als:
`FusspunktVorlauftemperatur + HeizkurvenSteigung * (20 - Aussentemperatur)`
Anschliessend wird sie auf `HeizkurveMinimaltemperatur` und
`HeizkurveMaximaltemperatur` begrenzt.
Die Einschaltschwelle wird je nach `MindesttemperaturModus` bestimmt:
- `Aus`: Solltemperatur minus Hysterese,
- `Statisch`: konfigurierte absolute Mindesttemperatur,
- `Differenz`: Solltemperatur minus konfigurierte Differenz.
Es gilt:
- im PV-Betrieb unter der Einschaltschwelle: `[0, ...Leistungsstufen]`,
- im PV-Betrieb an oder oberhalb der Einschaltschwelle: `[0]`,
- im Peakbetrieb unabhaengig von der Temperatur: `[0]`,
- bei `Aktiv=false`: immer `[0]`,
- bei ungueltigen Temperaturen: `[0]` und nicht verfuegbar.
Der Pufferspeicher ist im Peakbetrieb damit pauschal gesperrt. Beim Wechsel in
Peak wird eine aktive Stufe sicher ausgeschaltet.
## Properties
Die Properties erscheinen im Formular in dieser Reihenfolge.
### Manager und Zeitverhalten
| Ident | Standard | Beschreibung |
| --- | ---: | --- |
| `PrioritaetPV` | `0` | Prioritaet im PV-Betrieb |
| `PrioritaetPeak` | `0` | Prioritaet im Peakbetrieb |
| `Meldeintervall` | `10 s` | Vollstaendige Rueckmeldung |
| `VorgabeTimeout` | `120 s` | Gueltigkeit einer Vorgabe |
| `LastwechselSperrzeit` | `5 s` | Mindestzeit zwischen Lastwechseln |
### Pufferspeicher
| Ident | Standard | Beschreibung |
| --- | ---: | --- |
| `LeistungsStufen` | `[]` | Stufe, Leistung und Boolean-Schaltkontakt |
| `PufferfuehlerVariableID` | `0` | Integer- oder Floatvariable |
| `AussentemperaturVariableID` | `0` | Integer- oder Floatvariable |
### Heizkurve
| Ident | Standard | Beschreibung |
| --- | ---: | --- |
| `FusspunktVorlauftemperatur` | `35.0 C` | Vorlauf bei 20 C aussen |
| `HeizkurvenSteigung` | `1.0` | Anhebung je Kelvin fallender Aussentemperatur |
| `HeizkurveMinimaltemperatur` | `20.0 C` | Untere Sollwertbegrenzung |
| `HeizkurveMaximaltemperatur` | `80.0 C` | Obere Sollwertbegrenzung |
| `Hysterese` | `5.0 K` | Abstand zur Einschaltschwelle |
| `MindesttemperaturModus` | `Aus` | Aus, statisch oder Differenz |
| `Mindesttemperatur` | `20.0 C` | Wert im statischen Modus |
| `MindesttemperaturDifferenz` | `5.0 K` | Wert im Differenzmodus |
| `WaermepumpenSolltemperaturVariableID` | `0` | Optionale Quelle fuer die WP-Uebernahme |
Der Button **Wert von Waermepumpe uebernehmen** berechnet den Fusspunkt so,
dass die lokale Heizkurve beim aktuellen Aussenwert durch den aktuellen
WP-Sollwert verlaeuft. Es entsteht keine dauerhafte Laufzeitkopplung.
### Erweiterte Einstellungen
| Ident | Standard | Beschreibung |
| --- | ---: | --- |
| `TemperaturMaxAlter` | `120 s` | Maximal zulaessiges Alter beider Messwerte |
| `PuffertemperaturGlaetten` | `false` | Aktiviert PT1-Glaettung |
| `ZeitKonstante` | `120 s` | PT1-Zeitkonstante |
| `EinstellungenInVisu` | `false` | Gemeinsame Verbraucheroption |
| `DiagnosevariablenAnzeigen` | `false` | Diagnosevariablen einblenden |
| `LoggingEin` | `false` | Debug-Protokoll aktivieren |
## Variablen
Immer vorhanden:
| Ident | Typ | Beschreibung |
| --- | --- | --- |
| `Puffertemperatur` | Float / Anzeige | Aktuelle Temperatur in °C. |
| `Aussentemperatur` | Float / Anzeige | Heizkurveneingang in °C. |
| `TemperaturenGueltig` | Boolean / Anzeige | Beide Quellen sind gültig. |
| `Solltemperatur` | Float / Anzeige | Berechneter und begrenzter Sollwert in °C. |
| `Heizbedarf` | Boolean / Anzeige | Bedarf unter Berücksichtigung der Hysterese. |
| `AktiveStufe` | Integer / Anzeige | Stufenkennung; `0` bedeutet aus. |
| `FusspunktVorlauftemperatur` | Float / lokal bedienbar | Wirksamer Parameter in °C. |
| `HeizkurvenSteigung` | Float / lokal bedienbar | Anhebung pro K fallender Aussentemperatur. |
| `Mindesttemperatur` | Float / lokal bedienbar | Untere Begrenzung in °C. |
| `Maximaltemperatur` | Float / lokal bedienbar | Obere Begrenzung in °C. |
| `Hysterese` | Float / lokal bedienbar | Ein unter Soll minus Hysterese, aus bei Soll. |
| `Aktiv` | Boolean / bedienbar | Lokale Ein-/Aus-Freigabe |
| `Puffertemperatur` | Float | Aktueller, gegebenenfalls geglaetteter Wert |
| `Aussentemperatur` | Float | Heizkurveneingang |
| `Solltemperatur` | Float | Berechneter und begrenzter Sollwert |
| `Heizbedarf` | Boolean | Temperatur liegt unter der Einschaltschwelle |
## Zusätzliche Properties
Optional sichtbar sind die gemeinsamen Verbraucher-Diagnosevariablen sowie
`AktiveStufe`, `BezogeneEnergie` und `TemperaturenGueltig`.
| Ident | Typ | Standard / Beschreibung |
| --- | --- | --- |
| `FusspunktVorlauftemperatur` | Float | `0*` °C; Vorlauf bei 20 °C aussen. |
| `HeizkurvenSteigung` | Float | `0*`; mindestens 0. |
| `Mindesttemperatur` | Float | `0*` °C. |
| `Maximaltemperatur` | Float | `0*` °C. |
| `Hysterese` | Float | `1` K; grösser 0. |
| `Leistungsstufen` | String/JSON | `[]`; Stufenkennung, Leistung in W und Ausgangsbelegung. |
| `PuffertemperaturVariableID` | Integer | `0`; Temperaturquelle. |
| `AussentemperaturVariableID` | Integer | `0`; Aussentemperaturquelle. |
| `Mindestlaufzeit` | Integer | `0` s. |
| `Mindeststillstand` | Integer | `0` s. |
## Managerkommunikation
Vier bisherige Heizkurventemperaturen, beide Heizkurvenquellen, Übernahmeaktion,
Zeitplan und Zeitzone entfallen.
Das Modul implementiert `VerbraucherSchnittstelle` und Vertrag `4.0`.
Es uebernimmt zuerst die vom Manager angekuendigte Betriebsart, berechnet das
passende Leistungsangebot und meldet es mit derselben Betriebsart zurueck.
Erst danach wird eine Sollleistung angenommen.
## Zustand
Die Rueckmeldung enthaelt zusaetzlich:
`Puffertemperatur_C` und `Aussentemperatur_C` als Istwerte,
`Solltemperatur_C` als Sollwert, `Heizbedarf` als Status und `Fuehlerfehler` als
Störung.
- `Puffertemperatur_C`,
- `Aussentemperatur_C`,
- `Solltemperatur_C`,
- `Heizbedarf`,
- `AktiveStufe`,
- `Fuehlerfehler` und `Schaltfehler`.
## Offene Punkte
## Sicherheit und Schaltung
- Optionale Einbindung der Wärmepumpe beim Pufferheizen ausarbeiten.
Die Kontakte werden Break-before-make geschaltet: zuerst alle aus, danach
hoechstens eine Stufe ein. Ein Schaltfehler fuehrt zu einem Ausschaltversuch
aller Kontakte und einer Stoerungsmeldung. Hardwareseitige Verriegelungen,
Temperaturbegrenzer und Schutzorgane bleiben erforderlich.
## Tests
- `PufferspeicherReglerTest` prueft Heizkurve, Hysterese, Ein/Aus, Peak und
alle Mindesttemperatur-Modi.
- `PufferspeicherModulstrukturTest` prueft Metadaten, Properties, Formular,
Vertrag und Schaltfolge.
- Der standardisierte Symcon-Test prueft die reale Modulinstanz und ist im
gemeinsamen Manifest sowie in der Manager-Matrix registriert.
+4 -3
View File
@@ -1,7 +1,8 @@
# EMS-Module und Modulentwürfe
> Manager, Warmwassererwaermer und Verbraucher 1-Stufig sind als installierbare
> IP-Symcon-Module umgesetzt. Die weiteren Ordner enthalten Besprechungsgrundlagen.
> Manager, Warmwassererwaermer, Verbraucher 1-Stufig und Ladestation
> Stand-Alone sind als installierbare IP-Symcon-Module umgesetzt. Die weiteren
> Ordner enthalten Besprechungsgrundlagen.
Alle steuerbaren Verbraucher verwenden die gemeinsamen Datenpunkte aus der
[EMS-Schnittstelle](../Schnittstelle.md). In den Modul-READMEs stehen deshalb
@@ -15,7 +16,7 @@ nur zusätzliche Properties, Variablen, Zustände und offene Punkte.
| [Pufferspeicher](Pufferspeicher/README.md) | Heizstufen nach Heizkurve |
| [Verbraucher 1-Stufig](Verbraucher-1-Stufig/README.md) | Ein-/Aus-Verbraucher (implementiert) |
| [Wärmepumpe](Waermepumpe/README.md) | Sperrkontakt oder SG Ready |
| [Ladestation Stand-Alone](Ladestation-Stand-Alone/README.md) | Direkte Geräteanbindung |
| [Ladestation Stand-Alone](Ladestation-Stand-Alone/README.md) | Direkte Geräteanbindung (implementiert) |
| [Ladestation Gateway](Ladestation-Gateway/README.md) | Ladestation am Easee Gateway |
| [Easee Gateway](Easee-Gateway/README.md) | Gemeinsame Easee-Kommunikation |
+20 -11
View File
@@ -1,12 +1,15 @@
# Verbraucher 1-Stufig
> Status: implementiert fuer IP-Symcon 8 und den Enelix-2-Vertrag `3.0`.
> Status: implementiert fuer IP-Symcon 8 und den Enelix-2-Vertrag `4.0`.
Das Modul schaltet einen elektrischen Ein-/Aus-Verbraucher. Im freien Zustand
meldet es dem Manager das Leistungsangebot `[0, Nennleistung]`. Waerend einer
Mindest-Ein- oder Mindest-Aus-Zeit, waehrend einer ausstehenden Rueckmeldung
und bei lokal erzwungener Tagesmindestlaufzeit wird nur der aktuelle
bestaetigte Leistungswert angeboten.
Das Modul schaltet einen elektrischen Ein-/Aus-Verbraucher. Im frei
schaltbaren Zustand meldet es bei PV `[0, Nennleistung]` und bei Peak `[0]`.
Ist die Tagesmindestlaufzeit faellig, wird bei PV die Nennleistung erzwungen.
Bei Peak ist konfigurierbar, ob `[0, Nennleistung]` angeboten oder die
Nennleistung ohne Sperrmoeglichkeit erzwungen wird. Waerend einer
Mindest-Ein- oder Mindest-Aus-Zeit sowie bei einer ausstehenden Rueckmeldung
wird unabhaengig von der Betriebsart nur der aktuelle bestaetigte
Leistungswert angeboten.
## Architekturentscheidung
@@ -27,7 +30,7 @@ Regelberechnung.
## Properties
Zu den sechs gemeinsamen Verbraucher-Properties aus
[`Schnittstelle.md`](../../Schnittstelle.md) kommen acht
[`Schnittstelle.md`](../../Schnittstelle.md) kommen neun
Modul-Properties hinzu.
| Ident | Typ | Standard | Beschreibung |
@@ -37,6 +40,7 @@ Modul-Properties hinzu.
| `SchaltkontaktInvertiert` | Boolean | `false` | Kehrt die Ein-/Aus-Semantik des Aktors um. |
| `RueckmeldungVariableID` | Integer | `0` | Optionale Boolean-Rueckmeldung; `true` bedeutet physisch eingeschaltet. |
| `Mindestlaufzeit` | Integer | `0` | Geforderte Laufzeit pro lokalem Kalendertag in Sekunden, maximal 86400. |
| `PeakSperreBeiMindestlaufzeitAnbieten` | Boolean | `true` | Bietet bei faelliger Tagesmindestlaufzeit im Peakbetrieb zusaetzlich `0` als Sperre an. Bei `false` wird die Nennleistung lokal erzwungen. |
| `Mindesteinschaltdauer` | Integer | `5` | Mindestzeit in Sekunden, die ein bestaetigter Ein-Zustand gehalten wird. |
| `Mindestausschaltdauer` | Integer | `5` | Mindestzeit in Sekunden, die ein bestaetigter Aus-Zustand gehalten wird. |
| `DiagnosevariablenAnzeigen` | Boolean | `false` | Legt gemeinsame und modulspezifische Diagnosevariablen an. |
@@ -126,15 +130,20 @@ beruecksichtigt. Nur der bestaetigte Ein-Zustand zaehlt.
Ist die konfigurierte Tagesmindestlaufzeit noch nicht erreicht, wird der
Verbraucher zum spaetestmoeglichen Zeitpunkt angefordert, an dem die
Restlaufzeit bis Mitternacht noch erfuellt werden kann. Eine noch aktive
Mindestausschaltdauer hat dabei Vorrang; eine unmoegliche Restlaufzeit kann
nicht rueckwirkend nachgeholt werden.
Restlaufzeit bis Mitternacht noch erfuellt werden kann. Im PV-Betrieb wird die
Nennleistung dann lokal erzwungen. Im Peakbetrieb meldet das Modul bei
aktivierter Property `PeakSperreBeiMindestlaufzeitAnbieten` wie Enelix 1
`[0, Nennleistung]`, sodass der Manager zwischen Sperren und Nachholen der
Laufzeit waehlen kann. Ist die Property deaktiviert, wird auch im Peakbetrieb
nur die Nennleistung angeboten und lokal erzwungen. Eine noch aktive
Mindestausschaltdauer hat Vorrang; eine unmoegliche Restlaufzeit kann nicht
rueckwirkend nachgeholt werden.
## Managerkommunikation
Das Modul implementiert
`VerbraucherSchnittstelle::ManagerdatenEmpfangen()` und verwendet
ausschliesslich Vertrag `3.0`. Der Verbraucher besitzt keine
ausschliesslich Vertrag `4.0`. Der Verbraucher besitzt keine
Manager-ID-Property. Er akzeptiert nur Manager, in deren manueller oder
automatischer Verbraucherzuordnung seine Instanz aktiv eingetragen ist.
+32 -31
View File
@@ -1,12 +1,12 @@
# Verbraucher Warmwassererwaermer
> Status: implementiert fuer IP-Symcon 8.0+ und den Enelix-2-Vertrag `3.0`.
> Status: implementiert fuer IP-Symcon 8.0+ und den Enelix-2-Vertrag `4.0`.
Das Modul bindet einen elektrischen Warmwasserspeicher mit einer oder mehreren
exklusiven Leistungsstufen an den Enelix-Manager an. Der Manager kann nur eine
der aktuell angebotenen Leistungen vorgeben. Lokale Schutz- und Komfortregeln
fuer Mindesttemperatur, Maximaltemperatur, Zeitplan und Legionellenfunktion
haben Vorrang vor einer Manager-Vorgabe.
der aktuell angebotenen Leistungen vorgeben. Mindesttemperatur, Hysterese,
Zeitplan und Legionellenfunktion bestimmen dabei zustands- und
betriebsartabhaengig das Leistungsangebot.
Das Modul ist eine gezielte Adaption des Enelix-1-Moduls
`Boiler_x_Stufig`. Zyklische Altlogik und alte Kommunikationsvariablen wurden
@@ -20,7 +20,7 @@ ausschliesslich den Enelix-2-Nachrichtenvertrag.
| Modul-ID | `{B7C54AF4-AD7D-4FE4-B75D-203693906251}` |
| Modultyp | Geraeteinstanz (`3`) |
| Funktionspraefix | `ENELIX` |
| Enelix-Vertrag | `3.0` |
| Enelix-Vertrag | `4.0` |
## Funktionsumfang
@@ -65,22 +65,22 @@ Eine Neuberechnung wird insbesondere ausgeloest durch:
vollstaendige Rueckmeldung sicher. Die Lastwechselsperre verwendet einen
einmaligen Timer und loest nach ihrem Ablauf genau eine Neuberechnung aus.
Die Zielentscheidung folgt dieser Prioritaet:
Die Zielentscheidung folgt der Enelix-1-Zustandsmatrix:
1. Bei deaktivierter Instanz oder ungueltiger Temperatur ist der Verbraucher
nicht verfuegbar und wird ausgeschaltet. Eine ungueltige Konfiguration wird
bereits beim Uebernehmen mit Status `202` abgewiesen.
2. Unterschreitet die Temperatur die wirksame Mindesttemperatur oder reicht die
verbleibende Zeit bis zum naechsten Zeitplanziel rechnerisch nicht aus, wird
die hoechste Leistungsstufe lokal erzwungen.
3. Ab der wirksamen Maximaltemperatur wird ausgeschaltet.
4. Im Bereich unmittelbar unter der Maximaltemperatur wird eine bereits aktive
Stufe bis zum Erreichen der Abschaltgrenze gehalten.
5. In allen anderen Zustaenden darf der Manager zwischen `0 W` und allen
konfigurierten Leistungsstufen waehlen.
2. Unter der wirksamen Mindesttemperatur sowie in der unteren Hysterese bei
aktiver Stufe gilt: PV erzwingt die Maximalleistung, Peak bietet
`[0, ...Leistungsstufen]` an.
3. Ausserhalb dieses Mindesttemperaturbereichs bietet Peak nur `[0]` an.
4. Im PV-Betrieb werden unterhalb von `Maximaltemperatur - Hysterese` alle
Stufen angeboten. In der oberen Hysterese gilt dies nur, solange bereits
eine Stufe aktiv ist.
5. An oder oberhalb der wirksamen Maximaltemperatur wird ausgeschaltet.
Lokale Zwangsvorgaben werden mit `AenderungMoeglich=false` und einem
entsprechend eingeschraenkten `Leistungswerte_W` gemeldet.
Eine erzwungene PV-Maximalleistung wird mit `AenderungMoeglich=false`
gemeldet. Das Peak-Array bleibt dagegen durch den Manager waehlbar.
## Konfiguration
@@ -129,10 +129,11 @@ Stufe konfiguriert werden.
Der jeweils naechste Zeitplaneintrag wird fuer heute oder den folgenden Tag
ermittelt. Reicht die verbleibende Zeit bei maximaler elektrischer Leistung
rechnerisch nicht mehr aus, wird sofort die hoechste Stufe angefordert. Die
Berechnung verwendet Wasser mit `4186 J/(kg K)` und beruecksichtigt keine
Speicher- oder Leitungsverluste. Fuer Datum und Uhrzeit gilt die in IP-Symcon
eingestellte lokale Zeitzone.
rechnerisch nicht mehr aus, wird das Zeitplanziel zur wirksamen
Mindesttemperatur. Im PV-Betrieb wird dann die hoechste Stufe erzwungen, im
Peakbetrieb werden alle Stufen angeboten. Die Berechnung verwendet Wasser mit
`4186 J/(kg K)` und beruecksichtigt keine Speicher- oder Leitungsverluste. Fuer
Datum und Uhrzeit gilt die in IP-Symcon eingestellte lokale Zeitzone.
### Erweiterte sonstige Einstellungen
@@ -179,14 +180,14 @@ nur erlaubt, wenn `EinstellungenInVisu=true` gesetzt ist.
## Hysterese und lokales Nachladen
Sinkt die Temperatur unter die wirksame Mindesttemperatur, beginnt das lokale
Nachladen mit der hoechsten Leistungsstufe. Es endet, sobald mindestens
`Mindesttemperatur + Hysterese` erreicht ist. Oberhalb der wirksamen
Maximaltemperatur wird ausgeschaltet.
Sinkt die Temperatur unter die wirksame Mindesttemperatur, erzwingt PV die
hoechste Leistungsstufe; Peak bietet dagegen Aus und alle Stufen an. In der
unteren Hysterese bis `Mindesttemperatur + Hysterese` bleibt dieses Verhalten
nur erhalten, solange bereits eine Stufe aktiv ist.
Ist bereits eine Stufe aktiv und liegt die Temperatur bei mindestens
`Maximaltemperatur - Hysterese`, wird diese Stufe bis zur Abschaltgrenze
gehalten. Dadurch werden unnoetige Stufenwechsel kurz vor dem Ziel vermieden.
Im PV-Betrieb werden unter `Maximaltemperatur - Hysterese` alle Stufen
angeboten. Innerhalb der oberen Hysterese bleibt das Angebot nur bei einer
bereits aktiven Stufe erhalten. An der Maximaltemperatur wird ausgeschaltet.
## Legionellenfunktion
@@ -196,8 +197,8 @@ Die Legionellenfunktion arbeitet zweistufig:
wirksamen Maximaltemperatur. Der Manager kann die Aufheizung innerhalb des
verbleibenden Zeitfensters ermoeglichen.
2. Ab `LegionellenMaximalintervallTage` wird die Legionellentemperatur auch zur
wirksamen Mindesttemperatur. Die hoechste Stufe wird dadurch lokal
erzwungen, bis das Ziel erreicht ist.
wirksamen Mindesttemperatur. PV erzwingt dadurch die Maximalleistung; Peak
bietet das vollstaendige Stufenarray bis zum Ziel an.
Erreicht die gueltige Speichertemperatur die Legionellentemperatur, wird der
Zeitpunkt als erfolgreicher Abschluss gespeichert und das Intervall beginnt
@@ -268,7 +269,7 @@ Anzeigevariablen.
Das Modul implementiert
`VerbraucherSchnittstelle::ManagerdatenEmpfangen()` und verwendet den
Enelix-2-Vertrag `3.0`. Es besitzt keine Manager-ID-Property. Zugelassen sind
Enelix-2-Vertrag `4.0`. Es besitzt keine Manager-ID-Property. Zugelassen sind
nur Manager, in deren manueller oder automatischer Verbraucherzuordnung die
Instanz aktiv eingetragen ist.
@@ -382,7 +383,7 @@ PT1, Lastwechselsperre, Energieberechnung, thermische Prognose, Zeitplan und
Legionellengrenzen.
`WarmwassererwaermerModulstrukturTest.php` prueft Metadaten, Formular, alle 19
Properties, ereignisbasierte Lastwechselsperre, Vertrag `3.0`, bedarfsgesteuerte
Properties, ereignisbasierte Lastwechselsperre, Vertrag `4.0`, bedarfsgesteuerte
Diagnosevariablen, Legionellentemperatur, Temperatursollwerte und
Break-before-make-Schaltung.
+1 -1
View File
@@ -42,7 +42,7 @@ werden, wenn sie vor dem Lauf nicht existierten.
- `single`: genau die als Auswahl übergebenen Module
- `affected`: die durch geänderte Pfade ermittelten Module
Verfügbare Module: `Manager`, `VerbraucherEinStufig`, `Warmwassererwaermer`.
Verfügbare Module: `LadestationStandAlone`, `Manager`, `VerbraucherEinStufig`, `Warmwassererwaermer`.
Der Manager-Test enthält Manager ohne Verbraucher, jeden Verbrauchertyp einzeln und alle aktuell implementierten Verbrauchertypen gemeinsam.
+136 -27
View File
@@ -6,6 +6,7 @@ const ENELIX_DEMO_ROOT_IDENT = 'ENELIX_DEMO_2';
const ENELIX_DEMO_MANAGER_MODULE = '{6F771B18-59D4-4C8A-B951-3B2FE9F6A2C4}';
const ENELIX_DEMO_BOILER_MODULE = '{B7C54AF4-AD7D-4FE4-B75D-203693906251}';
const ENELIX_DEMO_SINGLE_MODULE = '{15879A4E-D0C2-4495-83DE-46E1E462591E}';
const ENELIX_DEMO_DUMMY_MODULE = '{485D0419-BE97-4548-AA9C-C083EB82E61E}';
const ENELIX_DEMO_TILE_MODULE = '{B5B875BB-9B76-45FD-4E67-2607E45B3AC4}';
const ENELIX_DEMO_ARCHIVE_MODULE = '{43192F0B-135B-4CE7-A0A7-1475603F3060}';
const ENELIX_DEMO_ENERGY_DISTRIBUTION_MODULE = '{BA90FDB3-C203-8F26-B4D6-F3DB99905724}';
@@ -37,12 +38,14 @@ function enelixDemoInstall(): array
$boiler2Power = enelixDemoEnsureVariable($dashboard, 'Boiler2Leistung', 'Boiler 2 Leistung', 2, 'ENELIX.Demo.PowerPositive', 0.0, 70);
$dryerPower = enelixDemoEnsureVariable($dashboard, 'EntfeuchterLeistung', 'Entfeuchter Leistung', 2, 'ENELIX.Demo.PowerPositive', 0.0, 80);
$powerChart = enelixDemoEnsureVariable($history, 'Leistungsdiagramm', 'Leistungsverlauf', 3, '~HTMLBox', '', 10);
$energyChart = enelixDemoEnsureVariable($history, 'Energiediagramm', 'Kumulierte Energien', 3, '~HTMLBox', '', 20);
$pvEnergy = enelixDemoEnsureVariable($history, 'PVEnergie', 'PV-Energie', 2, 'ENELIX.Demo.Energy', 0.0, 30);
$houseEnergy = enelixDemoEnsureVariable($history, 'HausEnergie', 'Hausverbrauch Energie', 2, 'ENELIX.Demo.Energy', 0.0, 40);
$gridImportEnergy = enelixDemoEnsureVariable($history, 'NetzbezugEnergie', 'Netzbezug Energie', 2, 'ENELIX.Demo.Energy', 0.0, 50);
$gridExportEnergy = enelixDemoEnsureVariable($history, 'EinspeisungEnergie', 'Einspeisung Energie', 2, 'ENELIX.Demo.Energy', 0.0, 60);
$powerChart = enelixDemoEnsureVariable($history, 'Leistungsdiagramm', 'Leistungsverlauf (Legacy)', 3, '~HTMLBox', '', 90);
$energyChart = enelixDemoEnsureVariable($history, 'Energiediagramm', 'Kumulierte Energien (Legacy)', 3, '~HTMLBox', '', 100);
IPS_SetHidden($powerChart, true);
IPS_SetHidden($energyChart, true);
$pvEnergy = enelixDemoEnsureVariable($history, 'PVEnergie', 'PV-Energie', 2, '~Electricity', 0.0, 30);
$houseEnergy = enelixDemoEnsureVariable($history, 'HausEnergie', 'Hausverbrauch Energie', 2, '~Electricity', 0.0, 40);
$gridImportEnergy = enelixDemoEnsureVariable($history, 'NetzbezugEnergie', 'Netzbezug Energie', 2, '~Electricity', 0.0, 50);
$gridExportEnergy = enelixDemoEnsureVariable($history, 'EinspeisungEnergie', 'Einspeisung Energie', 2, '~Electricity', 0.0, 60);
$auto = enelixDemoEnsureVariable($simulation, 'Automatik', 'Tageslauf automatisch', 0, '~Switch', true, 10);
$hour = enelixDemoEnsureVariable($simulation, 'Tageszeit', 'Simulierte Tageszeit', 2, 'ENELIX.Demo.Hour', 8.0, 20);
@@ -89,6 +92,21 @@ function enelixDemoInstall(): array
$chartUpdated = enelixDemoEnsureVariable($technical, 'LetztesDiagrammUpdate', 'Letztes Diagramm-Update', 1, '', 0, 170);
IPS_SetHidden($chartUpdated, true);
$flowPv = enelixDemoEnsureVariable($technical, 'EnergieflussPVkW', 'PV-Leistung', 2, '~Power', 0.0, 300);
$flowGrid = enelixDemoEnsureVariable($technical, 'EnergieflussNetzkW', 'Netzleistung', 2, '~Power', 0.0, 310);
$flowBase = enelixDemoEnsureVariable($technical, 'EnergieflussGrundlastkW', 'Haus-Grundlast', 2, '~Power', 0.0, 320);
$flowBoiler1 = enelixDemoEnsureVariable($technical, 'EnergieflussBoiler1kW', 'Boiler 1', 2, '~Power', 0.0, 330);
$flowBoiler2 = enelixDemoEnsureVariable($technical, 'EnergieflussBoiler2kW', 'Boiler 2', 2, '~Power', 0.0, 340);
$flowDryer = enelixDemoEnsureVariable($technical, 'EnergieflussEntfeuchterkW', 'Entfeuchter', 2, '~Power', 0.0, 350);
$flowHouse = enelixDemoEnsureVariable($technical, 'DiagrammHausverbrauchkW', 'Hausverbrauch gesamt', 2, '~Power', 0.0, 360);
$flowLoads = enelixDemoEnsureVariable($technical, 'DiagrammVerbraucherkW', 'Geregelte Verbraucher', 2, '~Power', 0.0, 370);
foreach ([
$flowPv, $flowGrid, $flowBase, $flowBoiler1,
$flowBoiler2, $flowDryer, $flowHouse, $flowLoads,
] as $flowVariableId) {
IPS_SetHidden($flowVariableId, true);
}
foreach ([$boiler11, $boiler12, $boiler21, $boiler22, $dryContact] as $actorId) {
IPS_SetVariableCustomAction($actorId, $actorScript);
}
@@ -148,19 +166,24 @@ function enelixDemoInstall(): array
$energyDistribution = enelixDemoEnsureInstance(
$dashboard,
'Energieverteilung',
'Energieverteilung',
'EnergieverteilungNativ',
'Energiefluss',
ENELIX_DEMO_ENERGY_DISTRIBUTION_MODULE,
10
);
enelixDemoConfigureEnergyDistribution($energyDistribution, [
enelixDemoEnergyNode($pvDisplay, 'PV-Anlage', 'solar'),
enelixDemoEnergyNode($grid, 'Stromnetz', 'grid'),
enelixDemoEnergyNode($base, 'Haus-Grundlast', 'consumer'),
enelixDemoEnergyNode($boiler1Power, 'Boiler 1', 'consumer', $temp1),
enelixDemoEnergyNode($boiler2Power, 'Boiler 2', 'consumer', $temp2),
enelixDemoEnergyNode($dryerPower, 'Entfeuchter', 'consumer'),
enelixDemoEnergyNode($flowPv, 'PV-Anlage', 'solar'),
enelixDemoEnergyNode($flowGrid, 'Stromnetz', 'grid'),
enelixDemoEnergyNode($flowBase, 'Haus-Grundlast', 'consumer'),
enelixDemoEnergyNode($flowBoiler1, 'Boiler 1', 'consumer', $temp1),
enelixDemoEnergyNode($flowBoiler2, 'Boiler 2', 'consumer', $temp2),
enelixDemoEnergyNode($flowDryer, 'Entfeuchter', 'consumer'),
]);
IPS_SetHidden($energyDistribution, false);
$oldEnergyDistribution = enelixDemoFind($dashboard, 'Energieverteilung');
if ($oldEnergyDistribution > 0 && $oldEnergyDistribution !== $energyDistribution) {
IPS_SetHidden($oldEnergyDistribution, true);
}
foreach ([$boiler1, $boiler2] as $boilerId) {
IPS_RequestAction($boilerId, 'Mindesttemperatur', 42.0);
@@ -194,16 +217,37 @@ function enelixDemoInstall(): array
}
IPS_RequestAction($manager, 'Aktiv', true);
enelixDemoLinkInstanceVariables($boiler1View, $boiler1, $temp1);
enelixDemoLinkInstanceVariables($boiler2View, $boiler2, $temp2);
$boiler1List = enelixDemoEnsureInstance(
$dashboard,
'Boiler1Liste',
'Boiler 1',
ENELIX_DEMO_DUMMY_MODULE,
110
);
$boiler2List = enelixDemoEnsureInstance(
$dashboard,
'Boiler2Liste',
'Boiler 2',
ENELIX_DEMO_DUMMY_MODULE,
120
);
enelixDemoConfigure($boiler1List, []);
enelixDemoConfigure($boiler2List, []);
enelixDemoLinkInstanceVariables($boiler1List, $boiler1, $temp1);
enelixDemoLinkInstanceVariables($boiler2List, $boiler2, $temp2);
IPS_SetHidden($boiler1View, true);
IPS_SetHidden($boiler2View, true);
IPS_SetHidden($boiler1, false);
IPS_SetHidden($boiler2, false);
IPS_SetHidden($boiler1, true);
IPS_SetHidden($boiler2, true);
foreach (['Boiler1Link', 'Boiler2Link'] as $oldLinkIdent) {
$oldLink = enelixDemoFind($dashboard, $oldLinkIdent);
if ($oldLink > 0) {
IPS_SetHidden($oldLink, true);
}
}
enelixDemoEnsureLink($dashboard, 'ManagerLink', 'Manager', $manager, 100);
enelixDemoEnsureLink($dashboard, 'Boiler1Link', 'Boiler 1', $boiler1, 110);
enelixDemoEnsureLink($dashboard, 'Boiler2Link', 'Boiler 2', $boiler2, 120);
enelixDemoEnsureLink($dashboard, 'EntfeuchterLink', 'Entfeuchter', $dryer, 130);
enelixDemoEnsureLink($simulation, 'Boiler1TemperaturLink', 'Boiler 1 Temperatur', $temp1, 70);
enelixDemoEnsureLink($simulation, 'Boiler2TemperaturLink', 'Boiler 2 Temperatur', $temp2, 80);
@@ -213,7 +257,21 @@ function enelixDemoInstall(): array
$boiler1Power, $boiler2Power, $dryerPower,
$pvEnergy, $houseEnergy, $gridImportEnergy, $gridExportEnergy,
$temp1, $temp2,
$flowPv, $flowGrid, $flowBase, $flowBoiler1, $flowBoiler2, $flowDryer,
$flowHouse, $flowLoads,
]);
enelixDemoEnsureChart($history, 'LeistungsdiagrammNativ', 'Leistungsverlauf', [
enelixDemoChartDataset($flowPv, 'PV', '#f4c542', '~Power'),
enelixDemoChartDataset($flowHouse, 'Hausverbrauch', '#2bb673', '~Power'),
enelixDemoChartDataset($flowLoads, 'Geregelte Verbraucher', '#2786ac', '~Power'),
enelixDemoChartDataset($flowGrid, 'Netzleistung', '#e95d5d', '~Power'),
], 10);
enelixDemoEnsureChart($history, 'EnergiediagrammNativ', 'Kumulierte Energien', [
enelixDemoChartDataset($pvEnergy, 'PV', '#f4c542', '~Electricity'),
enelixDemoChartDataset($houseEnergy, 'Hausverbrauch', '#2bb673', '~Electricity'),
enelixDemoChartDataset($gridImportEnergy, 'Netzbezug', '#e95d5d', '~Electricity'),
enelixDemoChartDataset($gridExportEnergy, 'Einspeisung', '#38a3db', '~Electricity'),
], 20);
enelixDemoConfigureVisualization($root, $energyDistribution);
enelixDemoTick('Install');
@@ -297,6 +355,14 @@ function enelixDemoTick(string $source = 'Manual'): void
SetValue(enelixDemoRequiredId($dashboard, 'Boiler1Leistung'), (float) $boiler1);
SetValue(enelixDemoRequiredId($dashboard, 'Boiler2Leistung'), (float) $boiler2);
SetValue(enelixDemoRequiredId($dashboard, 'EntfeuchterLeistung'), (float) $dryer);
SetValue(enelixDemoRequiredId($technical, 'EnergieflussPVkW'), round($pv / 1000.0, 3));
SetValue(enelixDemoRequiredId($technical, 'EnergieflussNetzkW'), round($grid / 1000.0, 3));
SetValue(enelixDemoRequiredId($technical, 'EnergieflussGrundlastkW'), round($base / 1000.0, 3));
SetValue(enelixDemoRequiredId($technical, 'EnergieflussBoiler1kW'), round($boiler1 / 1000.0, 3));
SetValue(enelixDemoRequiredId($technical, 'EnergieflussBoiler2kW'), round($boiler2 / 1000.0, 3));
SetValue(enelixDemoRequiredId($technical, 'EnergieflussEntfeuchterkW'), round($dryer / 1000.0, 3));
SetValue(enelixDemoRequiredId($technical, 'DiagrammHausverbrauchkW'), round($house / 1000.0, 3));
SetValue(enelixDemoRequiredId($technical, 'DiagrammVerbraucherkW'), round($power / 1000.0, 3));
$history = enelixDemoRequiredId($root, 'Historie');
$energyIds = [
@@ -313,11 +379,6 @@ function enelixDemoTick(string $source = 'Manual'): void
enelixDemoAddEnergy($energyIds['export'], max(0.0, -$grid), $hours);
}
$chartUpdated = enelixDemoRequiredId($technical, 'LetztesDiagrammUpdate');
if ($source !== 'Timer' || time() - (int) GetValue($chartUpdated) >= 10) {
enelixDemoUpdateCharts($history, [$pvId, $houseId, $gridId], $energyIds);
SetValue($chartUpdated, time());
}
}
function enelixDemoRenderFlow(array $state): string
@@ -378,6 +439,7 @@ function enelixDemoValidateRuntime(): void
ENELIX_DEMO_MANAGER_MODULE,
ENELIX_DEMO_BOILER_MODULE,
ENELIX_DEMO_SINGLE_MODULE,
ENELIX_DEMO_DUMMY_MODULE,
ENELIX_DEMO_ENERGY_DISTRIBUTION_MODULE,
] as $moduleId) {
if (!in_array($moduleId, $available, true)) {
@@ -451,6 +513,53 @@ function enelixDemoEnsureVariable(int $parent, string $ident, string $name, int
return $id;
}
function enelixDemoEnsureChart(
int $parent,
string $ident,
string $name,
array $datasets,
int $position
): int {
$id = enelixDemoFind($parent, $ident);
if ($id === 0) {
$id = IPS_CreateMedia(4);
IPS_SetParent($id, $parent);
IPS_SetIdent($id, $ident);
}
enelixDemoRequireType($id, 5);
if ((int) IPS_GetMedia($id)['MediaType'] !== 4) {
throw new RuntimeException('Medientyp-Konflikt bei ' . $ident);
}
IPS_SetName($id, $name);
IPS_SetPosition($id, $position);
IPS_SetHidden($id, false);
IPS_SetMediaFile($id, 'media/' . $id . '.chart', false);
IPS_SetMediaContent(
$id,
base64_encode(json_encode(['datasets' => $datasets], JSON_THROW_ON_ERROR))
);
return $id;
}
function enelixDemoChartDataset(
int $variableId,
string $title,
string $strokeColor,
string $profile
): array {
return [
'variableID' => $variableId,
'fillColor' => 'clear',
'strokeColor' => $strokeColor,
'timeOffset' => 0,
'visible' => true,
'title' => $title,
'type' => 'line',
'side' => 'left',
'profile' => $profile,
];
}
function enelixDemoEnsureScript(int $parent, string $ident, string $name, string $content, int $position): int
{
$id = enelixDemoFind($parent, $ident);
@@ -642,12 +751,12 @@ function enelixDemoEnergyNode(
return [
'VariableConfigType' => 0,
'VariableID' => $variableId,
'SecondaryVariableID' => 0,
'SecondaryVariableID' => 1,
'Name' => $name,
'Type' => $type,
'Grouped' => false,
'CalculationType' => 0,
'InfoVariableID' => $infoVariableId,
'InfoVariableID' => $infoVariableId > 0 ? $infoVariableId : 1,
'Invert' => false,
];
}
+12 -2
View File
@@ -18,7 +18,9 @@ final class EinStufigRegler
int $istleistung,
bool $verfuegbar,
bool $aenderungMoeglich,
bool $mindestlaufzeitErzwungen
bool $mindestlaufzeitErzwungen,
bool $peakbetrieb,
bool $peakSperreBeiMindestlaufzeitAnbieten = true
): array {
if ($nennleistung <= 0) {
throw new InvalidArgumentException('Die Nennleistung muss groesser als 0 sein.');
@@ -27,9 +29,17 @@ final class EinStufigRegler
throw new InvalidArgumentException('Die Istleistung muss 0 oder der Nennleistung entsprechen.');
}
if (!$verfuegbar || !$aenderungMoeglich || $mindestlaufzeitErzwungen) {
if (!$verfuegbar || !$aenderungMoeglich) {
return [$istleistung];
}
if ($mindestlaufzeitErzwungen) {
return $peakbetrieb && $peakSperreBeiMindestlaufzeitAnbieten
? [0, $nennleistung]
: [$nennleistung];
}
if ($peakbetrieb) {
return [0];
}
return [0, $nennleistung];
}
+208
View File
@@ -0,0 +1,208 @@
<?php
declare(strict_types=1);
namespace Belevo\EnelixEMS;
use InvalidArgumentException;
use RuntimeException;
final class LadestationAdapter
{
public const GO_E_ALT = 1;
public const GO_E_GEMINI = 2;
public const SMART_ME_PICO = 3;
/**
* @param array<string, string> $konfiguration
* @return array<string, mixed>
*/
public static function statusAnfrage(int $geraetetyp, array $konfiguration): array
{
if ($geraetetyp === self::GO_E_ALT) {
return self::anfrage('GET', self::lokaleBasisUrl($konfiguration) . '/mqtt?payload=');
}
if ($geraetetyp === self::GO_E_GEMINI) {
return self::anfrage('GET', self::lokaleBasisUrl($konfiguration) . '/api/status');
}
if ($geraetetyp === self::SMART_ME_PICO) {
$id = self::pflichtwert($konfiguration, 'GeraeteID');
return self::anfrage(
'GET',
'https://api.smart-me.com/pico/charging/' . rawurlencode($id),
$konfiguration
);
}
throw new InvalidArgumentException('Unbekannter Geraetetyp.');
}
/**
* @param array<string, string> $konfiguration
* @return list<array<string, mixed>>
*/
public static function steuerAnfragen(
int $geraetetyp,
array $konfiguration,
int $ladestrom
): array {
if ($ladestrom < 0 || $ladestrom > 32 || ($ladestrom > 0 && $ladestrom < 6)) {
throw new InvalidArgumentException('Ladestrom muss 0 oder 6 bis 32 A betragen.');
}
if ($geraetetyp === self::GO_E_ALT) {
$basis = self::lokaleBasisUrl($konfiguration) . '/mqtt?payload=';
if ($ladestrom === 0) {
return [self::anfrage('GET', $basis . 'alw=0')];
}
return [
self::anfrage('GET', $basis . 'alw=1'),
self::anfrage('GET', $basis . 'amp=' . $ladestrom),
];
}
if ($geraetetyp === self::GO_E_GEMINI) {
$basis = self::lokaleBasisUrl($konfiguration) . '/api/set?';
if ($ladestrom === 0) {
return [self::anfrage('GET', $basis . 'frc=1&sua=false')];
}
return [
self::anfrage('GET', $basis . 'frc=2'),
self::anfrage('GET', $basis . 'amp=' . $ladestrom),
];
}
if ($geraetetyp === self::SMART_ME_PICO) {
$seriennummer = self::pflichtwert($konfiguration, 'Seriennummer');
return [self::anfrage(
'POST',
'https://api.smart-me.com/pico/loadmanagementgroup/current/'
. rawurlencode($seriennummer)
. '?current=' . ($ladestrom * 1000),
$konfiguration
)];
}
throw new InvalidArgumentException('Unbekannter Geraetetyp.');
}
/**
* @return array{Rohstatus: int, Istleistung_W: float}
*/
public static function dekodiereStatus(int $geraetetyp, string $antwort): array
{
$daten = json_decode($antwort, true, 512, JSON_THROW_ON_ERROR);
if (!is_array($daten)) {
throw new RuntimeException('Die Geraeteantwort ist kein JSON-Objekt.');
}
if ($geraetetyp === self::GO_E_ALT || $geraetetyp === self::GO_E_GEMINI) {
if (!isset($daten['car'], $daten['nrg'])
|| !is_numeric($daten['car'])
|| !is_array($daten['nrg'])
|| !isset($daten['nrg'][11])
|| !is_numeric($daten['nrg'][11])
) {
throw new RuntimeException('Die go-e-Antwort enthaelt nicht alle Statusfelder.');
}
$faktor = $geraetetyp === self::GO_E_ALT ? 10.0 : 1.0;
return [
'Rohstatus' => (int) $daten['car'],
'Istleistung_W' => (float) $daten['nrg'][11] * $faktor,
];
}
if ($geraetetyp === self::SMART_ME_PICO) {
if (!isset($daten['State'], $daten['ActiveChargingPower'])
|| !is_numeric($daten['State'])
|| !is_numeric($daten['ActiveChargingPower'])
) {
throw new RuntimeException('Die Pico-Antwort enthaelt nicht alle Statusfelder.');
}
return [
'Rohstatus' => (int) $daten['State'],
'Istleistung_W' => round((float) $daten['ActiveChargingPower'] * 1000.0),
];
}
throw new InvalidArgumentException('Unbekannter Geraetetyp.');
}
/**
* @param array<string, string> $konfiguration
* @return array<string, mixed>
*/
private static function anfrage(
string $methode,
string $url,
array $konfiguration = []
): array {
$anfrage = ['Methode' => $methode, 'URL' => $url];
if (str_starts_with($url, 'https://api.smart-me.com/')) {
$anfrage['Benutzername'] = self::pflichtwert($konfiguration, 'Benutzername');
$anfrage['Passwort'] = self::pflichtwert($konfiguration, 'Passwort');
}
return $anfrage;
}
/** @param array<string, string> $konfiguration */
private static function lokaleBasisUrl(array $konfiguration): string
{
$adresse = trim(self::pflichtwert($konfiguration, 'Geraeteadresse'));
if (str_contains($adresse, '/') || str_contains($adresse, '?')) {
throw new InvalidArgumentException('Geraeteadresse muss ein Hostname oder eine IP-Adresse sein.');
}
return 'http://' . $adresse;
}
/** @param array<string, string> $konfiguration */
private static function pflichtwert(array $konfiguration, string $name): string
{
$wert = trim($konfiguration[$name] ?? '');
if ($wert === '') {
throw new InvalidArgumentException($name . ' darf nicht leer sein.');
}
return $wert;
}
}
final class LadestationTransport
{
/** @var callable(array<string, mixed>): string */
private $anfrageAusfuehren;
/** @param callable(array<string, mixed>): string $anfrageAusfuehren */
public function __construct(callable $anfrageAusfuehren)
{
$this->anfrageAusfuehren = $anfrageAusfuehren;
}
/** @param array<string, string> $konfiguration */
public function statusLesen(int $geraetetyp, array $konfiguration): string
{
return ($this->anfrageAusfuehren)(
LadestationAdapter::statusAnfrage($geraetetyp, $konfiguration)
);
}
/** @param array<string, string> $konfiguration */
public function ladestromSetzen(
int $geraetetyp,
array $konfiguration,
int $ladestrom
): void {
foreach (LadestationAdapter::steuerAnfragen($geraetetyp, $konfiguration, $ladestrom) as $anfrage) {
($this->anfrageAusfuehren)($anfrage);
}
}
}
+146
View File
@@ -0,0 +1,146 @@
<?php
declare(strict_types=1);
namespace Belevo\EnelixEMS;
use InvalidArgumentException;
final class LadestationRegler
{
public const PHASEN_UNBEKANNT = 0;
public const PHASEN_EIN = 1;
public const PHASEN_DREI = 3;
/** @var list<int> */
private const LEISTUNGEN_EINPHASIG = [
1450, 1690, 1930, 2170, 2410, 2650, 2890, 3130, 3370,
3610, 3850, 4090, 4330, 4570, 4810, 5050, 5290, 5530,
5770, 6010, 6250, 6490, 6730, 6970, 7210, 7450, 7690,
];
/** @var list<int> */
private const LEISTUNGEN_DREIPHASIG = [
4150, 4850, 5550, 6250, 6950, 7600, 8300, 9000, 9700,
10300, 11000, 11750, 12450, 13150, 13850, 14550, 15250,
15900, 16600, 17300, 18000, 18700, 19400, 20100, 20800,
21500, 22000,
];
/**
* Die Schwelle, die Voll-Erkennung und der Zuschlag von 2.5 A stammen
* aus der Enelix-1-Ladestation und bleiben bewusst kompatibel.
*
* @param array{Rohstatus: int, Istleistung_W: float} $rohstatus
* @return array<string, int|float|bool>
*/
public static function normalisiereStatus(array $rohstatus, bool $warVerbunden): array
{
$verbunden = $rohstatus['Rohstatus'] !== 1;
$leistung = max(0.0, $rohstatus['Istleistung_W']);
$phasen = self::PHASEN_UNBEKANNT;
$ladestrom = 0.0;
$geladen = false;
if ($verbunden) {
$phasen = $leistung > 7500.0 ? self::PHASEN_DREI : self::PHASEN_EIN;
$nenner = $phasen === self::PHASEN_EIN ? 230.0 : 1.71 * 400.0;
$ladestrom = $leistung / $nenner;
$ermittelterMaximalstrom = 2.5 + $ladestrom;
$geladen = $warVerbunden && $ermittelterMaximalstrom < 6.0;
}
if (!$verbunden) {
$fahrzeugstatus = 1;
} elseif ($geladen) {
$fahrzeugstatus = 4;
} elseif ($leistung > 0.0) {
$fahrzeugstatus = 3;
} else {
$fahrzeugstatus = 2;
}
return [
'FahrzeugVerbunden' => $verbunden,
'FahrzeugGeladen' => $geladen,
'Fahrzeugstatus' => $fahrzeugstatus,
'Phasenzahl' => $phasen,
'Istleistung_W' => $leistung,
'Ladestrom_A' => $ladestrom,
];
}
/** @return list<int> */
public static function leistungsangebot(
bool $aktiv,
bool $ladefreigabe,
bool $verbunden,
bool $geladen,
int $phasenzahl,
int $maximalerLadestrom,
bool $solarladen,
bool $peakbetrieb
): array {
if (!$aktiv || !$ladefreigabe || !$verbunden || $geladen) {
return [0];
}
$stufen = self::leistungsstufen($phasenzahl, $maximalerLadestrom);
if ($stufen === []) {
return [0];
}
if ($solarladen) {
return $peakbetrieb ? [0] : array_merge([0], $stufen);
}
if ($peakbetrieb) {
return array_merge([0], $stufen);
}
return [$stufen[count($stufen) - 1]];
}
public static function stromFuerLeistung(int $leistung, int $phasenzahl): int
{
if ($leistung <= 0) {
return 0;
}
$stufen = self::stufenFuerPhasen($phasenzahl);
if ($leistung < $stufen[0]) {
return 0;
}
$strom = 6;
foreach ($stufen as $index => $schwelle) {
if ($leistung < $schwelle) {
break;
}
$strom = 6 + $index;
}
return min(32, $strom);
}
/** @return list<int> */
private static function leistungsstufen(int $phasenzahl, int $maximalerLadestrom): array
{
if ($maximalerLadestrom < 6 || $maximalerLadestrom > 32) {
throw new InvalidArgumentException('MaximalerLadestrom muss zwischen 6 und 32 A liegen.');
}
return array_slice(self::stufenFuerPhasen($phasenzahl), 0, $maximalerLadestrom - 5);
}
/** @return list<int> */
private static function stufenFuerPhasen(int $phasenzahl): array
{
if ($phasenzahl === self::PHASEN_EIN) {
return self::LEISTUNGEN_EINPHASIG;
}
if ($phasenzahl === self::PHASEN_DREI) {
return self::LEISTUNGEN_DREIPHASIG;
}
throw new InvalidArgumentException('Phasenzahl muss 1 oder 3 sein.');
}
}
+2 -2
View File
@@ -16,8 +16,8 @@ use InvalidArgumentException;
final class ManagerRegler
{
public const BETRIEBSART_INAKTIV = 'Inaktiv';
public const BETRIEBSART_PV = 'PV';
public const BETRIEBSART_PEAK = 'Peak';
public const BETRIEBSART_PV = Nachrichtenvertrag::BETRIEBSART_PV;
public const BETRIEBSART_PEAK = Nachrichtenvertrag::BETRIEBSART_PEAK;
/**
* @param array<int, float|int> $monatsgrenzen Monatsnummer 1 bis 12 als Schluessel
+22 -4
View File
@@ -8,7 +8,10 @@ use InvalidArgumentException;
final class Nachrichtenvertrag
{
public const VERSION = '3.0';
public const VERSION = '4.0';
public const BETRIEBSART_PV = 'PV';
public const BETRIEBSART_PEAK = 'Peak';
public const LEISTUNGSQUELLE_NICHT_VORHANDEN = 0;
public const LEISTUNGSQUELLE_BERECHNET = 1;
@@ -19,11 +22,12 @@ final class Nachrichtenvertrag
*/
public static function pruefeManagerdaten(array $daten): void
{
self::pruefeSchluessel($daten, ['Kopf', 'Sollleistung_W'], 'Managerdaten');
self::pruefeSchluessel($daten, ['Kopf', 'Betriebsart', 'Sollleistung_W'], 'Managerdaten');
self::pruefeKopf($daten['Kopf']);
self::pruefeBetriebsart($daten['Betriebsart']);
if (!is_int($daten['Sollleistung_W'])) {
throw new InvalidArgumentException('Sollleistung_W muss eine Ganzzahl sein.');
if ($daten['Sollleistung_W'] !== null && !is_int($daten['Sollleistung_W'])) {
throw new InvalidArgumentException('Sollleistung_W muss eine Ganzzahl oder null sein.');
}
}
@@ -36,6 +40,7 @@ final class Nachrichtenvertrag
$daten,
[
'Kopf',
'Betriebsart',
'PrioritaetPV',
'PrioritaetPeak',
'Leistungswerte_W',
@@ -49,6 +54,7 @@ final class Nachrichtenvertrag
);
self::pruefeKopf($daten['Kopf']);
self::pruefeBetriebsart($daten['Betriebsart']);
self::pruefePrioritaet($daten['PrioritaetPV'], 'PrioritaetPV');
self::pruefePrioritaet($daten['PrioritaetPeak'], 'PrioritaetPeak');
self::pruefeLeistungswerte($daten['Leistungswerte_W']);
@@ -93,6 +99,18 @@ final class Nachrichtenvertrag
}
}
/** @param mixed $betriebsart */
private static function pruefeBetriebsart($betriebsart): void
{
if (!is_string($betriebsart) || !in_array(
$betriebsart,
[self::BETRIEBSART_PV, self::BETRIEBSART_PEAK],
true
)) {
throw new InvalidArgumentException('Betriebsart muss PV oder Peak sein.');
}
}
/**
* @param mixed $prioritaet
*/
+214
View File
@@ -0,0 +1,214 @@
<?php
declare(strict_types=1);
namespace Belevo\EnelixEMS;
use InvalidArgumentException;
final class PufferspeicherRegler
{
public const MINIMUM_AUS = 0;
public const MINIMUM_STATISCH = 1;
public const MINIMUM_DIFFERENZ = 2;
/**
* @return list<array{Stufe: int, Leistung_W: int, KontaktID: int}>
*/
public static function dekodiereLeistungsstufen(string $json): array
{
$werte = json_decode($json, true, 512, JSON_THROW_ON_ERROR);
if (!is_array($werte) || !self::istListe($werte)) {
throw new InvalidArgumentException('LeistungsStufen muss eine JSON-Liste sein.');
}
$ergebnis = [];
$leistungen = [];
$kontakte = [];
foreach ($werte as $index => $eintrag) {
if (!is_array($eintrag)) {
throw new InvalidArgumentException('Jede Leistungsstufe muss ein Objekt sein.');
}
$stufe = $eintrag['Stufe'] ?? $index + 1;
$leistung = $eintrag['Leistung'] ?? null;
$kontakt = $eintrag['Schaltkontakt_Stufe'] ?? null;
if (!is_int($stufe) || $stufe <= 0) {
throw new InvalidArgumentException('Stufe muss eine positive Ganzzahl sein.');
}
if (!is_int($leistung) || $leistung <= 0) {
throw new InvalidArgumentException('Leistung muss eine positive Ganzzahl sein.');
}
if (!is_int($kontakt) || $kontakt <= 0) {
throw new InvalidArgumentException('Schaltkontakt_Stufe muss eine positive Variablen-ID sein.');
}
if (isset($leistungen[$leistung])) {
throw new InvalidArgumentException('Leistungen muessen eindeutig sein.');
}
if (isset($kontakte[$kontakt])) {
throw new InvalidArgumentException('Schaltkontakte duerfen nur einer Stufe zugeordnet sein.');
}
$leistungen[$leistung] = true;
$kontakte[$kontakt] = true;
$ergebnis[] = [
'Stufe' => $stufe,
'Leistung_W' => $leistung,
'KontaktID' => $kontakt,
];
}
usort(
$ergebnis,
static fn (array $links, array $rechts): int => $links['Leistung_W'] <=> $rechts['Leistung_W']
);
return $ergebnis;
}
/**
* @param list<array{Stufe: int, Leistung_W: int, KontaktID: int}> $stufen
* @return list<int>
*/
public static function leistungswerte(array $stufen): array
{
return array_merge([0], array_column($stufen, 'Leistung_W'));
}
/**
* @param list<array{Stufe: int, Leistung_W: int, KontaktID: int}> $stufen
*/
public static function stufeFuerLeistung(array $stufen, int $leistung): int
{
if ($leistung === 0) {
return 0;
}
foreach ($stufen as $stufe) {
if ($stufe['Leistung_W'] === $leistung) {
return $stufe['Stufe'];
}
}
throw new InvalidArgumentException('Die Sollleistung entspricht keiner konfigurierten Leistungsstufe.');
}
public static function solltemperatur(
float $aussentemperatur,
float $fusspunkt,
float $steigung,
float $minimum,
float $maximum
): float {
if ($steigung < 0.0) {
throw new InvalidArgumentException('HeizkurvenSteigung darf nicht negativ sein.');
}
if ($minimum > $maximum) {
throw new InvalidArgumentException('Heizkurvenminimum darf nicht groesser als das Maximum sein.');
}
$soll = $fusspunkt + $steigung * (20.0 - $aussentemperatur);
return min($maximum, max($minimum, $soll));
}
public static function einschaltschwelle(
float $solltemperatur,
float $hysterese,
int $minimumModus,
float $statischeMindesttemperatur,
float $mindesttemperaturDifferenz
): float {
if ($hysterese <= 0.0) {
throw new InvalidArgumentException('Hysterese muss groesser als 0 sein.');
}
if ($minimumModus === self::MINIMUM_AUS) {
return $solltemperatur - $hysterese;
}
if ($minimumModus === self::MINIMUM_STATISCH) {
return min($solltemperatur, $statischeMindesttemperatur);
}
if ($minimumModus === self::MINIMUM_DIFFERENZ) {
if ($mindesttemperaturDifferenz < 0.0) {
throw new InvalidArgumentException('MindesttemperaturDifferenz darf nicht negativ sein.');
}
return $solltemperatur - $mindesttemperaturDifferenz;
}
throw new InvalidArgumentException('MindesttemperaturModus muss 0, 1 oder 2 sein.');
}
/**
* @param list<array{Stufe: int, Leistung_W: int, KontaktID: int}> $stufen
* @return list<int>
*/
public static function leistungsangebot(
bool $aktiv,
bool $peakbetrieb,
bool $temperaturenGueltig,
float $puffertemperatur,
float $einschaltschwelle,
array $stufen
): array {
if (!$aktiv || $peakbetrieb || !$temperaturenGueltig || $stufen === []) {
return [0];
}
if ($puffertemperatur < $einschaltschwelle) {
return self::leistungswerte($stufen);
}
return [0];
}
public static function fusspunktAusWaermepumpenwert(
float $waermepumpenSolltemperatur,
float $aussentemperatur,
float $steigung
): float {
if ($steigung < 0.0) {
throw new InvalidArgumentException('HeizkurvenSteigung darf nicht negativ sein.');
}
return $waermepumpenSolltemperatur - $steigung * (20.0 - $aussentemperatur);
}
public static function pt1(float $bisher, float $eingang, int $deltaSekunden, int $zeitkonstante): float
{
if ($deltaSekunden <= 0 || $zeitkonstante <= 0) {
throw new InvalidArgumentException('PT1-Zeiten muessen groesser als 0 sein.');
}
$alpha = $deltaSekunden / ($zeitkonstante + $deltaSekunden);
return $bisher + $alpha * ($eingang - $bisher);
}
public static function restzeitBisLastwechsel(
int $letzterLastwechsel,
int $jetzt,
int $sperrzeitSekunden
): int {
if ($sperrzeitSekunden < 0) {
throw new InvalidArgumentException('LastwechselSperrzeit darf nicht negativ sein.');
}
if ($letzterLastwechsel <= 0) {
return 0;
}
return max(0, $sperrzeitSekunden - max(0, $jetzt - $letzterLastwechsel));
}
public static function energieKWh(float $leistungW, int $dauerSekunden): float
{
if ($dauerSekunden < 0) {
throw new InvalidArgumentException('Dauer darf nicht negativ sein.');
}
return $leistungW * $dauerSekunden / 3600000.0;
}
/** @param array<mixed> $wert */
private static function istListe(array $wert): bool
{
return $wert === [] || array_keys($wert) === range(0, count($wert) - 1);
}
}
+34
View File
@@ -78,6 +78,40 @@ final class WarmwasserRegler
return array_merge([0], array_column($stufen, 'Leistung_W'));
}
/**
* @param list<int> $leistungswerte
* @return list<int>
*/
public static function leistungsangebot(
bool $peakbetrieb,
float $temperatur,
float $mindesttemperatur,
float $maximaltemperatur,
float $hysterese,
bool $stufeAktiv,
array $leistungswerte
): array {
if ($hysterese < 0.0 || $leistungswerte === []) {
throw new InvalidArgumentException('Hysterese und Leistungswerte sind ungueltig.');
}
$mindestladung = $temperatur < $mindesttemperatur
|| ($temperatur < $mindesttemperatur + $hysterese && $stufeAktiv);
if ($peakbetrieb) {
return $mindestladung ? $leistungswerte : [0];
}
if ($mindestladung) {
return [max($leistungswerte)];
}
if ($temperatur < $maximaltemperatur - $hysterese
|| ($temperatur < $maximaltemperatur && $stufeAktiv)
) {
return $leistungswerte;
}
return [0];
}
/**
* @param list<array{Stufe: int, Leistung_W: int, KontaktID: int}> $stufen
*/
+64 -1
View File
@@ -62,7 +62,7 @@ final class DemoanlageBootstrapTest extends TestCase
self::assertSame([
'VariableConfigType' => 0,
'VariableID' => 12345,
'SecondaryVariableID' => 0,
'SecondaryVariableID' => 1,
'Name' => 'PV-Anlage',
'Type' => 'solar',
'Grouped' => false,
@@ -70,6 +70,69 @@ final class DemoanlageBootstrapTest extends TestCase
'InfoVariableID' => 54321,
'Invert' => false,
], enelixDemoEnergyNode(12345, 'PV-Anlage', 'solar', 54321));
self::assertSame(
1,
enelixDemoEnergyNode(12345, 'PV-Anlage', 'solar')['InfoVariableID']
);
}
public function testNativeDiagrammDatenreiheHatSymconFormat(): void
{
self::assertSame([
'variableID' => 12345,
'fillColor' => 'clear',
'strokeColor' => '#f4c542',
'timeOffset' => 0,
'visible' => true,
'title' => 'PV',
'type' => 'line',
'side' => 'left',
'profile' => '~Power',
], enelixDemoChartDataset(12345, 'PV', '#f4c542', '~Power'));
}
public function testDashboardVerwendetNativeEnergieverteilungUndNeueBoilerlisten(): void
{
$source = file_get_contents(dirname(__DIR__) . '/examples/Demoanlage/bootstrap.php');
self::assertIsString($source);
self::assertStringContainsString("IPS_SetHidden(\$legacyFlow, true);", $source);
self::assertStringContainsString("'EnergieverteilungNativ'", $source);
self::assertStringContainsString(
'enelixDemoConfigureVisualization($root, $energyDistribution);',
$source
);
self::assertStringContainsString('ENELIX_DEMO_DUMMY_MODULE', $source);
self::assertStringContainsString("'Boiler1Liste'", $source);
self::assertStringContainsString("'Boiler2Liste'", $source);
self::assertStringContainsString("'EnergieflussPVkW'", $source);
self::assertStringContainsString("'~Power'", $source);
self::assertStringContainsString("'~Electricity'", $source);
self::assertStringContainsString(
"enelixDemoEnergyNode(\$flowPv, 'PV-Anlage', 'solar')",
$source
);
self::assertStringContainsString(
"enelixDemoEnergyNode(\$flowBase, 'Haus-Grundlast', 'consumer')",
$source
);
self::assertStringContainsString(
"enelixDemoEnergyNode(\$flowDryer, 'Entfeuchter', 'consumer')",
$source
);
self::assertStringContainsString(
"enelixDemoEnsureChart(\$history, 'LeistungsdiagrammNativ'",
$source
);
self::assertStringContainsString('IPS_SetHidden($powerChart, true);', $source);
self::assertStringContainsString(
"enelixDemoLinkInstanceVariables(\$boiler1List, \$boiler1, \$temp1);",
$source
);
self::assertStringContainsString(
"foreach (['Boiler1Link', 'Boiler2Link'] as \$oldLinkIdent)",
$source
);
}
public function testRollbackIstExplizitBestaetigungspflichtig(): void
+17 -2
View File
@@ -13,9 +13,7 @@ final class DokumentationsstrukturTest extends TestCase
{
return [
['Batterie'],
['Pufferspeicher'],
['Waermepumpe'],
['Ladestation-Stand-Alone'],
['Ladestation-Gateway'],
['Easee-Gateway'],
];
@@ -33,6 +31,23 @@ final class DokumentationsstrukturTest extends TestCase
self::assertStringContainsString('Properties', $inhalt);
}
public function testPufferspeicherDokumentiertDenImplementiertenStand(): void
{
$inhalt = file_get_contents(__DIR__ . '/../docs/module/Pufferspeicher/README.md');
self::assertNotFalse($inhalt);
self::assertStringContainsString('Status: implementiert', $inhalt);
self::assertStringContainsString('Managerkommunikation', $inhalt);
}
public function testLadestationStandAloneDokumentiertDenImplementiertenStand(): void
{
$inhalt = file_get_contents(__DIR__ . '/../docs/module/Ladestation-Stand-Alone/README.md');
self::assertNotFalse($inhalt);
self::assertStringContainsString('Status: Implementiert', $inhalt);
self::assertStringContainsString('Properties', $inhalt);
self::assertStringContainsString('Fake-HTTP-Transport', $inhalt);
}
public function testSchnittstellenDokumentiertSind(): void
{
self::assertFileExists(__DIR__ . '/../docs/Schnittstelle.md');
+171
View File
@@ -0,0 +1,171 @@
<?php
declare(strict_types=1);
namespace Belevo\EnelixEMS\Tests;
use Belevo\EnelixEMS\LadestationAdapter;
use Belevo\EnelixEMS\LadestationTransport;
use PHPUnit\Framework\TestCase;
final class LadestationAdapterTest extends TestCase
{
/**
* @dataProvider statusFaelle
* @param array<string, string> $konfiguration
* @param array{Rohstatus: int, Istleistung_W: float} $erwartet
*/
public function testStatusaufrufUndDekodierungFuerAlleGeraete(
int $geraetetyp,
array $konfiguration,
string $antwort,
string $erwarteteUrl,
array $erwartet
): void {
$aufrufe = [];
$transport = new LadestationTransport(static function (array $anfrage) use (
&$aufrufe,
$antwort
): string {
$aufrufe[] = $anfrage;
return $antwort;
});
$rohstatus = LadestationAdapter::dekodiereStatus(
$geraetetyp,
$transport->statusLesen($geraetetyp, $konfiguration)
);
self::assertSame($erwarteteUrl, $aufrufe[0]['URL']);
self::assertSame('GET', $aufrufe[0]['Methode']);
self::assertSame($erwartet, $rohstatus);
}
/** @return iterable<string, array<mixed>> */
public function statusFaelle(): iterable
{
yield 'go-e alt' => [
LadestationAdapter::GO_E_ALT,
$this->lokaleKonfiguration(),
$this->goEAntwort(2, 1100),
'http://192.0.2.10/mqtt?payload=',
['Rohstatus' => 2, 'Istleistung_W' => 11000.0],
];
yield 'go-e Gemini' => [
LadestationAdapter::GO_E_GEMINI,
$this->lokaleKonfiguration(),
$this->goEAntwort(2, 2300),
'http://192.0.2.10/api/status',
['Rohstatus' => 2, 'Istleistung_W' => 2300.0],
];
yield 'Smart-me Pico' => [
LadestationAdapter::SMART_ME_PICO,
$this->picoKonfiguration(),
json_encode([
'State' => 2,
'ActiveChargingPower' => 11.0,
'MaxAllowedChargingCurrent' => 16,
], JSON_THROW_ON_ERROR),
'https://api.smart-me.com/pico/charging/pico-id',
['Rohstatus' => 2, 'Istleistung_W' => 11000.0],
];
}
/**
* @dataProvider steuerFaelle
* @param array<string, string> $konfiguration
* @param list<array{Methode: string, URL: string}> $erwarteteAufrufe
*/
public function testSteueraufrufeWerdenUeberFakeTransportGeprueft(
int $geraetetyp,
array $konfiguration,
int $strom,
array $erwarteteAufrufe
): void {
$aufrufe = [];
$transport = new LadestationTransport(static function (array $anfrage) use (&$aufrufe): string {
unset($anfrage['Benutzername'], $anfrage['Passwort']);
$aufrufe[] = $anfrage;
return '{}';
});
$transport->ladestromSetzen($geraetetyp, $konfiguration, $strom);
self::assertSame($erwarteteAufrufe, $aufrufe);
}
/** @return iterable<string, array<mixed>> */
public function steuerFaelle(): iterable
{
yield 'go-e alt laden' => [
LadestationAdapter::GO_E_ALT,
$this->lokaleKonfiguration(),
16,
[
['Methode' => 'GET', 'URL' => 'http://192.0.2.10/mqtt?payload=alw=1'],
['Methode' => 'GET', 'URL' => 'http://192.0.2.10/mqtt?payload=amp=16'],
],
];
yield 'go-e Gemini stoppen' => [
LadestationAdapter::GO_E_GEMINI,
$this->lokaleKonfiguration(),
0,
[[
'Methode' => 'GET',
'URL' => 'http://192.0.2.10/api/set?frc=1&sua=false',
]],
];
yield 'Smart-me Pico laden' => [
LadestationAdapter::SMART_ME_PICO,
$this->picoKonfiguration(),
16,
[[
'Methode' => 'POST',
'URL' => 'https://api.smart-me.com/pico/loadmanagementgroup/current/serial-1?current=16000',
]],
];
}
public function testPicoAnfragenVerwendenBasicAuth(): void
{
$anfrage = LadestationAdapter::statusAnfrage(
LadestationAdapter::SMART_ME_PICO,
$this->picoKonfiguration()
);
self::assertSame('api-user', $anfrage['Benutzername']);
self::assertSame(hash('sha256', __FILE__), $anfrage['Passwort']);
}
/** @return array<string, string> */
private function lokaleKonfiguration(): array
{
return [
'Geraeteadresse' => '192.0.2.10',
'GeraeteID' => '',
'Seriennummer' => '',
'Benutzername' => '',
'Passwort' => '',
];
}
/** @return array<string, string> */
private function picoKonfiguration(): array
{
return [
'Geraeteadresse' => '',
'GeraeteID' => 'pico-id',
'Seriennummer' => 'serial-1',
'Benutzername' => 'api-user',
'Passwort' => hash('sha256', __FILE__),
];
}
private function goEAntwort(int $status, float $leistung): string
{
$nrg = array_fill(0, 12, 0);
$nrg[11] = $leistung;
return json_encode(['car' => $status, 'nrg' => $nrg], JSON_THROW_ON_ERROR);
}
}
+67
View File
@@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
namespace Belevo\EnelixEMS\Tests;
use Belevo\EnelixEMS\LadestationRegler;
use PHPUnit\Framework\TestCase;
final class LadestationReglerTest extends TestCase
{
public function testFahrzeugUndDreiPhasenWerdenWieInEnelixEinsErkannt(): void
{
$status = LadestationRegler::normalisiereStatus([
'Rohstatus' => 2,
'Istleistung_W' => 11000.0,
], false);
self::assertTrue($status['FahrzeugVerbunden']);
self::assertFalse($status['FahrzeugGeladen']);
self::assertSame(3, $status['Fahrzeugstatus']);
self::assertSame(LadestationRegler::PHASEN_DREI, $status['Phasenzahl']);
}
public function testEinphasigesFahrzeugWirdUnterhalb7500WErkannt(): void
{
$status = LadestationRegler::normalisiereStatus([
'Rohstatus' => 2,
'Istleistung_W' => 2300.0,
], false);
self::assertSame(LadestationRegler::PHASEN_EIN, $status['Phasenzahl']);
self::assertEqualsWithDelta(10.0, $status['Ladestrom_A'], 0.01);
}
public function testGeladenWirdNachVerbindungUnterSechsAmpereErkannt(): void
{
$status = LadestationRegler::normalisiereStatus([
'Rohstatus' => 2,
'Istleistung_W' => 0.0,
], true);
self::assertTrue($status['FahrzeugGeladen']);
self::assertSame(4, $status['Fahrzeugstatus']);
}
public function testBetriebsartUndSolarladenBestimmenDasLeistungsangebot(): void
{
$solarPV = LadestationRegler::leistungsangebot(true, true, true, false, 1, 16, true, false);
$normalPV = LadestationRegler::leistungsangebot(true, true, true, false, 1, 16, false, false);
$solarPeak = LadestationRegler::leistungsangebot(true, true, true, false, 1, 16, true, true);
$normalPeak = LadestationRegler::leistungsangebot(true, true, true, false, 1, 16, false, true);
self::assertSame(0, $solarPV[0]);
self::assertCount(12, $solarPV);
self::assertSame([3850], $normalPV);
self::assertSame([0], $solarPeak);
self::assertSame($solarPV, $normalPeak);
}
public function testPeakKannJedesFahrzeugAufNullSetzen(): void
{
self::assertSame(0, LadestationRegler::stromFuerLeistung(0, 3));
self::assertSame(6, LadestationRegler::stromFuerLeistung(4150, 3));
self::assertSame(16, LadestationRegler::stromFuerLeistung(11000, 3));
}
}
@@ -0,0 +1,73 @@
<?php
declare(strict_types=1);
namespace Belevo\EnelixEMS\Tests;
use PHPUnit\Framework\TestCase;
final class LadestationStandAloneModulstrukturTest extends TestCase
{
private const MODULPFAD = __DIR__ . '/../LadestationStandAlone/';
public function testModulIstVollstaendigUndMetadatenSindGueltig(): void
{
foreach (['module.json', 'form.json', 'module.php'] as $datei) {
self::assertFileExists(self::MODULPFAD . $datei);
}
$modul = json_decode(
(string) file_get_contents(self::MODULPFAD . 'module.json'),
true,
512,
JSON_THROW_ON_ERROR
);
self::assertSame('LadestationStandAlone', $modul['name']);
self::assertSame('8.0', json_decode(
(string) file_get_contents(__DIR__ . '/../library.json'),
true,
512,
JSON_THROW_ON_ERROR
)['compatibility']['version']);
}
public function testGemeinsamerVerbrauchervertragWirdVerwendet(): void
{
$modul = (string) file_get_contents(self::MODULPFAD . 'module.php');
self::assertStringContainsString('implements VerbraucherSchnittstelle', $modul);
self::assertStringContainsString('use VerbraucherBasisTrait;', $modul);
self::assertStringContainsString('ManagerdatenEmpfangen(array $daten)', $modul);
self::assertStringContainsString('Nachrichtenvertrag::pruefeVerbraucherdaten', $modul);
self::assertStringNotContainsString("RegisterVariableString('PowerSteps'", $modul);
}
public function testAlleDreiStandAloneGeraeteUndSichereZugangsdatenSindKonfiguriert(): void
{
$form = json_decode(
(string) file_get_contents(self::MODULPFAD . 'form.json'),
true,
512,
JSON_THROW_ON_ERROR
);
$json = json_encode($form, JSON_THROW_ON_ERROR);
self::assertStringContainsString('go-e Charger (alte API)', $json);
self::assertStringContainsString('go-e Charger Gemini', $json);
self::assertStringContainsString('Gemini flex', $json);
self::assertStringContainsString('smart-me Pico', $json);
self::assertStringContainsString('PasswordTextBox', $json);
self::assertStringNotContainsString('Testmodus', $json);
}
public function testVisualisierungsbuttonsLoggingUndDiagnoseSindVorhanden(): void
{
$modul = (string) file_get_contents(self::MODULPFAD . 'module.php');
self::assertStringContainsString("EnableAction('Ladefreigabe')", $modul);
self::assertStringContainsString("EnableAction('Solarladen')", $modul);
self::assertStringContainsString("ReadPropertyBoolean('LoggingEin')", $modul);
self::assertStringContainsString("ReadPropertyBoolean('EinstellungenInVisu')", $modul);
self::assertStringContainsString('UnregisterVariable($ident)', $modul);
}
}
+1
View File
@@ -168,6 +168,7 @@ final class ManagerReglerTest extends TestCase
'EmpfaengerID' => 1,
'Zeitpunkt' => 1788825600,
],
'Betriebsart' => Nachrichtenvertrag::BETRIEBSART_PV,
'PrioritaetPV' => $prioritaet,
'PrioritaetPeak' => $prioritaet,
'Leistungswerte_W' => $leistungswerte,
+17
View File
@@ -14,12 +14,28 @@ final class NachrichtenvertragTest extends TestCase
{
Nachrichtenvertrag::pruefeManagerdaten([
'Kopf' => $this->kopf(10001, 20001),
'Betriebsart' => Nachrichtenvertrag::BETRIEBSART_PV,
'Sollleistung_W' => 1501,
]);
Nachrichtenvertrag::pruefeManagerdaten([
'Kopf' => $this->kopf(10001, 20001),
'Betriebsart' => Nachrichtenvertrag::BETRIEBSART_PEAK,
'Sollleistung_W' => null,
]);
self::assertTrue(true);
}
public function testUngueltigeBetriebsartWirdAbgelehnt(): void
{
$daten = $this->verbraucherdaten();
$daten['Betriebsart'] = 'Unbekannt';
$this->expectException(InvalidArgumentException::class);
Nachrichtenvertrag::pruefeVerbraucherdaten($daten);
}
public function testGueltigeVerbraucherdatenMitEinzelwertenUndBereichenWerdenAkzeptiert(): void
{
Nachrichtenvertrag::pruefeVerbraucherdaten($this->verbraucherdaten());
@@ -92,6 +108,7 @@ final class NachrichtenvertragTest extends TestCase
{
return [
'Kopf' => $this->kopf(20001, 10001),
'Betriebsart' => Nachrichtenvertrag::BETRIEBSART_PEAK,
'PrioritaetPV' => 0,
'PrioritaetPeak' => 12,
'Leistungswerte_W' => [
+111
View File
@@ -0,0 +1,111 @@
<?php
declare(strict_types=1);
namespace Belevo\EnelixEMS\Tests;
use PHPUnit\Framework\TestCase;
final class PufferspeicherModulstrukturTest extends TestCase
{
private const MODULPFAD = __DIR__ . '/../Pufferspeicher/';
public function testModulIstVollstaendigUndMetadatenSindGueltig(): void
{
foreach (['module.json', 'form.json', 'module.php'] as $datei) {
self::assertFileExists(self::MODULPFAD . $datei);
}
$metadaten = json_decode(
(string) file_get_contents(self::MODULPFAD . 'module.json'),
true,
512,
JSON_THROW_ON_ERROR
);
self::assertSame('VerbraucherPufferspeicher', $metadaten['name']);
self::assertSame('Enelix', $metadaten['vendor']);
self::assertSame(['Pufferspeicher'], $metadaten['aliases']);
self::assertSame(3, $metadaten['type']);
}
public function testPropertiesSindRegistriertUndInGewuenschterReihenfolgeImFormular(): void
{
$basis = (string) file_get_contents(__DIR__ . '/../libs/VerbraucherBasisTrait.php');
$modul = (string) file_get_contents(self::MODULPFAD . 'module.php');
preg_match_all(
'/RegisterProperty(?:Integer|Float|Boolean|String)\(\'([^\']+)\'/',
$basis . $modul,
$treffer
);
$properties = array_values(array_unique($treffer[1]));
$formularText = (string) file_get_contents(self::MODULPFAD . 'form.json');
foreach ($properties as $property) {
self::assertStringContainsString('"name": "' . $property . '"', $formularText, $property);
}
$formular = json_decode($formularText, true, 512, JSON_THROW_ON_ERROR);
self::assertSame(
[
'Manager und Zeitverhalten',
'Pufferspeicher',
'Heizkurve',
'Erweiterte Einstellungen',
],
array_column($formular['elements'], 'caption')
);
}
public function testModulVerwendetVertrag40UndBetriebsartabhaengigesAngebot(): void
{
$modul = (string) file_get_contents(self::MODULPFAD . 'module.php');
self::assertStringContainsString('implements VerbraucherSchnittstelle', $modul);
self::assertStringContainsString('ManagerdatenEmpfangen(array $daten)', $modul);
self::assertStringContainsString("['Betriebsart']", $modul);
self::assertStringContainsString('BETRIEBSART_PEAK', $modul);
self::assertStringContainsString("'Betriebsart' =>", $modul);
self::assertStringNotContainsString("RegisterVariableString('PowerSteps'", $modul);
self::assertStringNotContainsString("RegisterPropertyInteger('ManagerID'", $modul);
}
public function testEinAusLoggingVariablenUndWaermepumpenaktionSindEnthalten(): void
{
$modul = (string) file_get_contents(self::MODULPFAD . 'module.php');
$formular = (string) file_get_contents(self::MODULPFAD . 'form.json');
self::assertStringContainsString("case 'Aktiv':", $modul);
self::assertStringContainsString("ReadPropertyBoolean('LoggingEin')", $modul);
foreach ([
'Puffertemperatur',
'Aussentemperatur',
'Solltemperatur',
'Heizbedarf',
] as $ident) {
self::assertStringContainsString("RegisterVariable", $modul);
self::assertStringContainsString("'" . $ident . "'", $modul);
}
self::assertStringContainsString('HeizkurveVonWaermepumpeUebernehmen', $modul);
self::assertStringContainsString('Wert von Waermepumpe uebernehmen', $formular);
}
public function testSchaltfolgeIstBreakBeforeMake(): void
{
$modul = (string) file_get_contents(self::MODULPFAD . 'module.php');
$aus = strpos($modul, "RequestAction(\$stufe['KontaktID'], false)");
$ein = strpos($modul, "RequestAction(\$stufe['KontaktID'], true)");
self::assertIsInt($aus);
self::assertIsInt($ein);
self::assertLessThan($ein, $aus);
}
public function testManagerKenntDieModulIDGenauEinmal(): void
{
$manager = (string) file_get_contents(__DIR__ . '/../Manager/module.php');
self::assertSame(1, substr_count(
$manager,
'{C92D5EEF-9632-47A5-9659-4B02BF40FBE9}'
));
}
}
+154
View File
@@ -0,0 +1,154 @@
<?php
declare(strict_types=1);
namespace Belevo\EnelixEMS\Tests;
use Belevo\EnelixEMS\PufferspeicherRegler;
use PHPUnit\Framework\TestCase;
final class PufferspeicherReglerTest extends TestCase
{
/** @return list<array{Stufe: int, Leistung_W: int, KontaktID: int}> */
private function stufen(): array
{
return [
['Stufe' => 1, 'Leistung_W' => 1000, 'KontaktID' => 101],
['Stufe' => 2, 'Leistung_W' => 2000, 'KontaktID' => 102],
];
}
public function testHeizkurveWirdBerechnetUndBegrenzt(): void
{
self::assertSame(
50.0,
PufferspeicherRegler::solltemperatur(5.0, 35.0, 1.0, 20.0, 80.0)
);
self::assertSame(
80.0,
PufferspeicherRegler::solltemperatur(-100.0, 35.0, 1.0, 20.0, 80.0)
);
self::assertSame(
20.0,
PufferspeicherRegler::solltemperatur(100.0, 35.0, 1.0, 20.0, 80.0)
);
}
public function testUnterErrechneterEinschaltschwelleWerdenAlleStufenAngeboten(): void
{
self::assertSame(
[0, 1000, 2000],
PufferspeicherRegler::leistungsangebot(
true,
false,
true,
44.9,
45.0,
$this->stufen()
)
);
}
public function testInHystereseUndDarueberWirdNurNullAngeboten(): void
{
self::assertSame(
[0],
PufferspeicherRegler::leistungsangebot(
true,
false,
true,
45.0,
45.0,
$this->stufen()
)
);
self::assertSame(
[0],
PufferspeicherRegler::leistungsangebot(
true,
false,
true,
51.0,
45.0,
$this->stufen()
)
);
}
public function testPeakbetriebBietetUnabhaengigVonDerTemperaturNurNull(): void
{
foreach ([-20.0, 44.9, 45.0, 100.0] as $temperatur) {
self::assertSame(
[0],
PufferspeicherRegler::leistungsangebot(
true,
true,
true,
$temperatur,
45.0,
$this->stufen()
)
);
}
}
public function testAusgeschaltetWirdImmerNurNullAngeboten(): void
{
self::assertSame(
[0],
PufferspeicherRegler::leistungsangebot(
false,
false,
true,
-20.0,
45.0,
$this->stufen()
)
);
}
public function testMindesttemperaturIstOptionalStatischOderDifferenzbasiert(): void
{
self::assertSame(
45.0,
PufferspeicherRegler::einschaltschwelle(
50.0,
5.0,
PufferspeicherRegler::MINIMUM_AUS,
30.0,
10.0
)
);
self::assertSame(
42.0,
PufferspeicherRegler::einschaltschwelle(
50.0,
5.0,
PufferspeicherRegler::MINIMUM_STATISCH,
42.0,
10.0
)
);
self::assertSame(
43.0,
PufferspeicherRegler::einschaltschwelle(
50.0,
5.0,
PufferspeicherRegler::MINIMUM_DIFFERENZ,
30.0,
7.0
)
);
}
public function testWaermepumpenwertKalibriertFusspunktAmAktuellenAussenwert(): void
{
$fusspunkt = PufferspeicherRegler::fusspunktAusWaermepumpenwert(50.0, 5.0, 1.0);
self::assertSame(35.0, $fusspunkt);
self::assertSame(
50.0,
PufferspeicherRegler::solltemperatur(5.0, $fusspunkt, 1.0, 0.0, 100.0)
);
}
}
+21
View File
@@ -3,6 +3,17 @@
declare(strict_types=1);
return [
'LadestationStandAlone' => [
'file' => __DIR__ . '/modules/LadestationStandAlone.php',
'paths' => [
'LadestationStandAlone/*',
'libs/LadestationAdapter.php',
'libs/LadestationRegler.php',
'libs/VerbraucherBasisTrait.php',
'libs/VerbraucherSchnittstelle.php',
'libs/Nachrichtenvertrag.php',
],
],
'Manager' => [
'file' => __DIR__ . '/modules/Manager.php',
'paths' => [
@@ -14,6 +25,16 @@ return [
'libs/VerbraucherSchnittstelle.php',
],
],
'Pufferspeicher' => [
'file' => __DIR__ . '/modules/Pufferspeicher.php',
'paths' => [
'Pufferspeicher/*',
'libs/PufferspeicherRegler.php',
'libs/VerbraucherBasisTrait.php',
'libs/VerbraucherSchnittstelle.php',
'libs/Nachrichtenvertrag.php',
],
],
'VerbraucherEinStufig' => [
'file' => __DIR__ . '/modules/VerbraucherEinStufig.php',
'paths' => [
@@ -0,0 +1,177 @@
<?php
declare(strict_types=1);
use Belevo\EnelixEMS\SymconTest\TestContext;
return static function (TestContext $test): void {
$testPasswort = hash('sha256', __FILE__);
$goEAntwort = static function (int $status, float $leistung): string {
$nrg = array_fill(0, 12, 0);
$nrg[11] = $leistung;
return json_encode(['car' => $status, 'nrg' => $nrg], JSON_THROW_ON_ERROR);
};
$definitionen = [
'go-e alt' => [
'Geraetetyp' => 1,
'Geraeteadresse' => '192.0.2.10',
'Testantwort' => $goEAntwort(2, 1100),
'Phasenzahl' => 3,
'BefehlUrlTeil' => 'amp=16',
],
'go-e Gemini' => [
'Geraetetyp' => 2,
'Geraeteadresse' => '192.0.2.11',
'Testantwort' => $goEAntwort(2, 2300),
'Phasenzahl' => 1,
'BefehlUrlTeil' => 'amp=16',
],
'smart-me Pico' => [
'Geraetetyp' => 3,
'GeraeteID' => 'pico-id',
'Seriennummer' => 'serial-1',
'Benutzername' => 'api-user',
'Passwort' => $testPasswort,
'Testantwort' => json_encode([
'State' => 2,
'ActiveChargingPower' => 11.0,
'MaxAllowedChargingCurrent' => 16,
], JSON_THROW_ON_ERROR),
'Phasenzahl' => 3,
'BefehlUrlTeil' => 'current=16000',
],
];
$stationen = [];
foreach ($definitionen as $name => $definition) {
$properties = array_merge($definition, [
'MaximalerLadestrom' => 16,
'Ladefreigabe' => true,
'Solarladen' => true,
'EinstellungenInVisu' => true,
'DiagnosevariablenAnzeigen' => true,
'Testmodus' => true,
]);
unset($properties['Phasenzahl'], $properties['BefehlUrlTeil']);
$stationen[$name] = $test->createInstance(
'{0D94913C-0F31-4C29-A685-6EB4AE58E55D}',
'Ladestation ' . $name,
$properties
);
}
$managerId = $test->createInstance(
'{6F771B18-59D4-4C8A-B951-3B2FE9F6A2C4}',
'Hilfsmanager Ladestation',
[
'AutomatischeSuche' => false,
'VerbraucherZuordnung' => json_encode(array_map(
static fn (int $id): array => ['InstanzID' => $id, 'Aktiv' => true],
array_values($stationen)
), JSON_THROW_ON_ERROR),
]
);
$anforderungSenden = static function (
int $stationId,
int $leistung,
string $betriebsart = 'PV'
) use ($managerId): void {
IPS_RequestAction($stationId, 'ManagerdatenEmpfangen', json_encode([
'Kopf' => [
'Version' => '4.0',
'AbsenderID' => $managerId,
'EmpfaengerID' => $stationId,
'Zeitpunkt' => time(),
],
'Betriebsart' => $betriebsart,
'Sollleistung_W' => $leistung,
], JSON_THROW_ON_ERROR));
};
$test->runCase('Alle drei Geraetevarianten erkennen Fahrzeug und Phasen', static function (
TestContext $test
) use ($definitionen, $stationen): void {
foreach ($stationen as $name => $stationId) {
IPS_RequestAction($stationId, 'Aktiv', true);
$test->assertSame(true, GetValue($test->objectByIdent('FahrzeugVerbunden', $stationId)));
$test->assertSame(
$definitionen[$name]['Phasenzahl'],
GetValue($test->objectByIdent('Phasenzahl', $stationId))
);
$test->assertSame(false, GetValue($test->objectByIdent('FahrzeugGeladen', $stationId)));
IPS_RequestAction($stationId, 'Solarladen', false);
$befehl = (string) GetValue($test->objectByIdent('LetzterGeraetebefehl', $stationId));
$diagnose = json_encode([
'Aktiv' => GetValue($test->objectByIdent('Aktiv', $stationId)),
'Ladefreigabe' => GetValue($test->objectByIdent('Ladefreigabe', $stationId)),
'Solarladen' => GetValue($test->objectByIdent('Solarladen', $stationId)),
'FahrzeugGeladen' => GetValue($test->objectByIdent('FahrzeugGeladen', $stationId)),
'Phasenzahl' => GetValue($test->objectByIdent('Phasenzahl', $stationId)),
'Status' => IPS_GetInstance($stationId)['InstanceStatus'],
'Stoertext' => GetValue($test->objectByIdent('Stoertext', $stationId)),
], JSON_THROW_ON_ERROR);
$test->assertTrue(str_contains(
$befehl,
$definitionen[$name]['BefehlUrlTeil']
), $name . ': Der erwartete 16-A-Geraetebefehl fehlt. Erhalten: ' . $befehl
. '; Diagnose: ' . $diagnose);
}
});
$test->runCase('Solar- und Peakbetrieb liefern die Enelix-1-Angebote', static function (
TestContext $test
) use ($stationen, $anforderungSenden): void {
$stationId = $stationen['go-e alt'];
IPS_RequestAction($stationId, 'Solarladen', true);
$anforderungSenden($stationId, 4150);
$befehl = (string) GetValue($test->objectByIdent('LetzterGeraetebefehl', $stationId));
$diagnose = json_encode([
'Sollleistung' => GetValue($test->objectByIdent('Sollleistung', $stationId)),
'SollwertGueltig' => GetValue($test->objectByIdent('SollwertGueltig', $stationId)),
'Status' => IPS_GetInstance($stationId)['InstanceStatus'],
'Stoertext' => GetValue($test->objectByIdent('Stoertext', $stationId)),
], JSON_THROW_ON_ERROR);
$test->assertTrue(str_contains(
$befehl,
'amp=6'
), 'Solarladen: Der 6-A-Befehl fehlt. Erhalten: ' . $befehl . '; Diagnose: ' . $diagnose);
IPS_RequestAction($stationId, 'Solarladen', false);
$angebot = (string) GetValue($test->objectByIdent('LeistungsangebotDiagnose', $stationId));
$test->assertSame('[11000]', $angebot, 'PV ohne Solarladen muss nur das Maximum anbieten.');
$abgewiesen = false;
try {
$anforderungSenden($stationId, 4150);
} catch (Throwable $fehler) {
$abgewiesen = true;
}
if (!$abgewiesen) {
$abgewiesen = GetValue($test->objectByIdent('SollwertGueltig', $stationId)) !== true
|| GetValue($test->objectByIdent('Sollleistung', $stationId)) !== 4150;
}
$test->assertSame(true, $abgewiesen, 'Normalbetrieb muss eine Zwischenstufe ablehnen.');
$anforderungSenden($stationId, 4150, 'Peak');
$test->assertSame(
'[0,4150,4850,5550,6250,6950,7600,8300,9000,9700,10300,11000]',
(string) GetValue($test->objectByIdent('LeistungsangebotDiagnose', $stationId))
);
$test->assertTrue(str_contains(
(string) GetValue($test->objectByIdent('LetzterGeraetebefehl', $stationId)),
'amp=6'
), 'Peak ohne Solarladen muss die Ladestufen annehmen.');
IPS_RequestAction($stationId, 'Solarladen', true);
$anforderungSenden($stationId, 0, 'Peak');
$test->assertSame(
'[0]',
(string) GetValue($test->objectByIdent('LeistungsangebotDiagnose', $stationId))
);
$test->assertTrue(str_contains(
(string) GetValue($test->objectByIdent('LetzterGeraetebefehl', $stationId)),
'alw=0'
), 'Peak mit Solarladen muss die Ladestation stoppen.');
});
};
+41 -13
View File
@@ -44,9 +44,33 @@ return static function (TestContext $test): void {
]
);
$pufferTemperaturId = $test->createVariable(2, 'Puffertemperatur', 52.0);
$aussenTemperaturId = $test->createVariable(2, 'Aussentemperatur', 5.0);
$pufferKontakte = [
$test->createActionVariable('Pufferspeicher Stufe 1'),
$test->createActionVariable('Pufferspeicher Stufe 2'),
];
$pufferspeicherId = $test->createInstance(
'{C92D5EEF-9632-47A5-9659-4B02BF40FBE9}',
'Pufferspeicher',
[
'PufferfuehlerVariableID' => $pufferTemperaturId,
'AussentemperaturVariableID' => $aussenTemperaturId,
'LeistungsStufen' => json_encode([
['Stufe' => 1, 'Leistung' => 1000, 'Schaltkontakt_Stufe' => $pufferKontakte[0]],
['Stufe' => 2, 'Leistung' => 2000, 'Schaltkontakt_Stufe' => $pufferKontakte[1]],
], JSON_THROW_ON_ERROR),
'LastwechselSperrzeit' => 0,
'PrioritaetPV' => 2,
'PrioritaetPeak' => 2,
'DiagnosevariablenAnzeigen' => true,
]
);
$zuordnung = [
['InstanzID' => $einStufigId, 'Aktiv' => true],
['InstanzID' => $warmwasserId, 'Aktiv' => true],
['InstanzID' => $pufferspeicherId, 'Aktiv' => true],
];
$lizenzcode = trim((string) getenv('ENELIX_TEST_LICENSE_CODE'));
$managerId = $test->createInstance(
@@ -67,8 +91,8 @@ return static function (TestContext $test): void {
]
);
$melden = static function () use ($einStufigId, $warmwasserId): void {
foreach ([$einStufigId, $warmwasserId] as $instanzId) {
$melden = static function () use ($einStufigId, $warmwasserId, $pufferspeicherId): void {
foreach ([$einStufigId, $warmwasserId, $pufferspeicherId] as $instanzId) {
IPS_RequestAction($instanzId, 'Aktiv', true);
IPS_RequestAction($instanzId, 'Melden', true);
}
@@ -77,10 +101,12 @@ return static function (TestContext $test): void {
$netzleistungId,
$managerId,
$einStufigId,
$warmwasserId
$warmwasserId,
$pufferspeicherId
): void {
IPS_RequestAction($einStufigId, 'Melden', true);
IPS_RequestAction($warmwasserId, 'Melden', true);
IPS_RequestAction($pufferspeicherId, 'Melden', true);
SetValue($netzleistungId, $netzleistung);
IPS_RequestAction($managerId, 'Regeln', true);
};
@@ -88,6 +114,8 @@ return static function (TestContext $test): void {
(bool) GetValue($einKontaktId),
(bool) GetValue($warmKontakte[0]),
(bool) GetValue($warmKontakte[1]),
(bool) GetValue($pufferKontakte[0]),
(bool) GetValue($pufferKontakte[1]),
];
$warteAufKontakte = static function (array $erwartet, string $schritt) use (
$test,
@@ -150,20 +178,20 @@ return static function (TestContext $test): void {
$test->runCase('Manager und alle vorhandenen Verbrauchertypen sind verknüpft', static function (
TestContext $test
) use ($managerId, $einStufigId, $warmwasserId, $melden): void {
) use ($managerId, $einStufigId, $warmwasserId, $pufferspeicherId, $melden): void {
IPS_RequestAction($managerId, 'Aktiv', true);
$melden();
IPS_RequestAction($managerId, 'Regeln', true);
foreach ([$managerId, $einStufigId, $warmwasserId] as $instanzId) {
foreach ([$managerId, $einStufigId, $warmwasserId, $pufferspeicherId] as $instanzId) {
$test->assertInstanceStatus($instanzId);
}
$test->assertSame(
2,
3,
GetValue($test->objectByIdent('VerbraucherAnzahl', $managerId))
);
$test->assertSame(
2,
3,
GetValue($test->objectByIdent('VerbraucherVerfuegbar', $managerId))
);
});
@@ -172,22 +200,22 @@ return static function (TestContext $test): void {
TestContext $test
) use ($managerId, $regeln, $warteAufKontakte): void {
$regeln(-3000.0);
$warteAufKontakte([true, false, true], 'Netz -3000 W');
$warteAufKontakte([true, false, true, false, false], 'Netz -3000 W');
$test->assertSame('PV', GetValue($test->objectByIdent('Betriebsart', $managerId)));
$regeln(-1000.0);
$warteAufKontakte([true, false, true], 'Netz -1000 W');
$warteAufKontakte([true, false, true, false, false], 'Netz -1000 W');
$regeln(0.0);
$warteAufKontakte([true, false, true], 'Netz 0 W');
$warteAufKontakte([true, false, true, false, false], 'Netz 0 W');
IPS_Sleep(1100);
$regeln(1000.0);
$warteAufKontakte([true, true, false], 'Netz +1000 W');
$warteAufKontakte([false, false, false, false, false], 'Netz +1000 W');
$test->assertSame('Peak', GetValue($test->objectByIdent('Betriebsart', $managerId)));
IPS_Sleep(1100);
$regeln(3000.0);
$warteAufKontakte([false, false, false], 'Netz +3000 W');
$warteAufKontakte([false, false, false, false, false], 'Netz +3000 W');
});
$test->runCase('Keep-alive ist aktiv und erneuert unveränderte Sollwerte', static function (
@@ -216,7 +244,7 @@ return static function (TestContext $test): void {
$melden();
IPS_RequestAction($managerId, 'Regeln', false);
$daten = $verbraucherstatus();
$test->assertSame(2, count($daten['Verbraucher'] ?? []));
$test->assertSame(3, count($daten['Verbraucher'] ?? []));
SetValue($temperaturId, 53.0);
$test->waitUntil(static function () use (
+155
View File
@@ -0,0 +1,155 @@
<?php
declare(strict_types=1);
use Belevo\EnelixEMS\SymconTest\TestContext;
return static function (TestContext $test): void {
$pufferId = $test->createVariable(2, 'Puffertemperatur', 40.0, null, '~Temperature');
$aussenId = $test->createVariable(2, 'Aussentemperatur', 5.0, null, '~Temperature');
$wpSollId = $test->createVariable(2, 'WP Solltemperatur', 50.0, null, '~Temperature');
$kontakte = [
$test->createActionVariable('Puffer Stufe 1'),
$test->createActionVariable('Puffer Stufe 2'),
];
$stufen = [
['Stufe' => 1, 'Leistung' => 1000, 'Schaltkontakt_Stufe' => $kontakte[0]],
['Stufe' => 2, 'Leistung' => 2000, 'Schaltkontakt_Stufe' => $kontakte[1]],
];
$verbraucherId = $test->createInstance(
'{C92D5EEF-9632-47A5-9659-4B02BF40FBE9}',
'Pufferspeicher',
[
'PufferfuehlerVariableID' => $pufferId,
'AussentemperaturVariableID' => $aussenId,
'WaermepumpenSolltemperaturVariableID' => $wpSollId,
'LeistungsStufen' => json_encode($stufen, JSON_THROW_ON_ERROR),
'FusspunktVorlauftemperatur' => 35.0,
'HeizkurvenSteigung' => 1.0,
'HeizkurveMinimaltemperatur' => 20.0,
'HeizkurveMaximaltemperatur' => 80.0,
'Hysterese' => 5.0,
'LastwechselSperrzeit' => 0,
'TemperaturMaxAlter' => 120,
'DiagnosevariablenAnzeigen' => true,
]
);
$managerId = $test->createInstance(
'{6F771B18-59D4-4C8A-B951-3B2FE9F6A2C4}',
'Hilfsmanager Pufferspeicher',
[
'AutomatischeSuche' => false,
'VerbraucherZuordnung' => json_encode([
['InstanzID' => $verbraucherId, 'Aktiv' => true],
], JSON_THROW_ON_ERROR),
]
);
$managerdatenSenden = static function (
string $betriebsart,
?int $leistung
) use ($managerId, $verbraucherId): void {
IPS_RequestAction($verbraucherId, 'ManagerdatenEmpfangen', json_encode([
'Kopf' => [
'Version' => '4.0',
'AbsenderID' => $managerId,
'EmpfaengerID' => $verbraucherId,
'Zeitpunkt' => time(),
],
'Betriebsart' => $betriebsart,
'Sollleistung_W' => $leistung,
], JSON_THROW_ON_ERROR));
};
$kontaktzustand = static fn (): array => array_map(
static fn (int $id): bool => (bool) GetValue($id),
$kontakte
);
$test->runCase('Instanz und Variablen werden angelegt', static function (
TestContext $test
) use ($verbraucherId): void {
$test->assertInstanceStatus($verbraucherId);
foreach ([
'Aktiv',
'Puffertemperatur',
'Aussentemperatur',
'Solltemperatur',
'Heizbedarf',
'Istleistung',
'Sollleistung',
'AktiveStufe',
'TemperaturenGueltig',
] as $ident) {
$test->objectByIdent($ident, $verbraucherId);
}
});
$test->runCase('Unter der Einschaltschwelle kann PV eine Stufe schalten', static function (
TestContext $test
) use ($verbraucherId, $managerdatenSenden, $kontaktzustand): void {
IPS_RequestAction($verbraucherId, 'Aktiv', true);
$managerdatenSenden('PV', null);
$test->assertSame(true, GetValue($test->objectByIdent('Heizbedarf', $verbraucherId)));
$managerdatenSenden('PV', 2000);
$test->waitUntil(static fn (): bool => $kontaktzustand() === [false, true]);
$test->assertSame([false, true], $kontaktzustand());
});
$test->runCase('In der Hysterese und oberhalb des Sollwerts wird ausgeschaltet', static function (
TestContext $test
) use ($pufferId, $verbraucherId, $kontaktzustand): void {
SetValue($pufferId, 47.0);
IPS_RequestAction($verbraucherId, 'Melden', true);
$test->waitUntil(static fn (): bool => $kontaktzustand() === [false, false]);
$test->assertSame(false, GetValue($test->objectByIdent('Heizbedarf', $verbraucherId)));
SetValue($pufferId, 55.0);
IPS_RequestAction($verbraucherId, 'Melden', true);
$test->assertSame([false, false], $kontaktzustand());
});
$test->runCase('Peakbetrieb sperrt den Pufferspeicher unabhaengig von der Temperatur', static function (
TestContext $test
) use ($pufferId, $verbraucherId, $managerdatenSenden, $kontaktzustand): void {
SetValue($pufferId, 20.0);
$managerdatenSenden('Peak', null);
$test->assertSame(false, GetValue($test->objectByIdent('Heizbedarf', $verbraucherId)));
$test->waitUntil(static fn (): bool => $kontaktzustand() === [false, false]);
$abgewiesen = false;
try {
$managerdatenSenden('Peak', 1000);
} catch (Throwable $fehler) {
$abgewiesen = true;
}
if (!$abgewiesen) {
$abgewiesen = GetValue($test->objectByIdent('SollwertGueltig', $verbraucherId)) !== true
|| GetValue($test->objectByIdent('Sollleistung', $verbraucherId)) !== 1000;
}
$test->assertSame(true, $abgewiesen, 'Peak darf keine Heizstufe anbieten.');
$test->assertSame([false, false], $kontaktzustand());
});
$test->runCase('Lokales Ausschalten schaltet jede Stufe aus', static function (
TestContext $test
) use ($verbraucherId, $kontaktzustand): void {
IPS_RequestAction($verbraucherId, 'Aktiv', false);
$test->waitUntil(static fn (): bool => $kontaktzustand() === [false, false]);
$test->assertSame([false, false], $kontaktzustand());
});
$test->runCase('WP-Button kalibriert den Heizkurven-Fusspunkt', static function (
TestContext $test
) use ($verbraucherId): void {
IPS_SetProperty($verbraucherId, 'FusspunktVorlauftemperatur', 30.0);
IPS_ApplyChanges($verbraucherId);
IPS_RequestAction($verbraucherId, 'HeizkurveVonWaermepumpeUebernehmen', 0);
$test->assertEquals(
35.0,
(float) IPS_GetProperty($verbraucherId, 'FusspunktVorlauftemperatur'),
0.001
);
});
};
+40 -3
View File
@@ -32,17 +32,21 @@ return static function (TestContext $test): void {
]
);
$anforderungSenden = static function (int $leistung) use (
$anforderungSenden = static function (
?int $leistung,
string $betriebsart = 'PV'
) use (
$managerId,
$verbraucherId
): void {
$paket = [
'Kopf' => [
'Version' => '3.0',
'Version' => '4.0',
'AbsenderID' => $managerId,
'EmpfaengerID' => $verbraucherId,
'Zeitpunkt' => time(),
],
'Betriebsart' => $betriebsart,
'Sollleistung_W' => $leistung,
];
IPS_RequestAction(
@@ -64,6 +68,28 @@ return static function (TestContext $test): void {
}
});
$test->runCase('PV und Peak liefern unterschiedliche Leistungsangebote', static function (
TestContext $test
) use ($verbraucherId, $anforderungSenden): void {
IPS_RequestAction($verbraucherId, 'Aktiv', true);
IPS_Sleep(2100);
$anforderungSenden(null, 'Peak');
$abgewiesen = false;
try {
$anforderungSenden(1200, 'Peak');
} catch (Throwable $fehler) {
$abgewiesen = true;
}
if (!$abgewiesen) {
$abgewiesen = GetValue($test->objectByIdent('SollwertGueltig', $verbraucherId)) !== true
|| GetValue($test->objectByIdent('Sollleistung', $verbraucherId)) !== 1200;
}
$test->assertSame(true, $abgewiesen, 'Peak darf die Nennleistung nicht anbieten.');
$anforderungSenden(null, 'PV');
});
$test->runCase('Nur angebotene Leistungsanforderungen werden angenommen', static function (
TestContext $test
) use (
@@ -143,7 +169,7 @@ return static function (TestContext $test): void {
SetValue($rueckmeldungId, true);
});
$test->runCase('Tägliche Mindestlaufzeit wird spätestens rechtzeitig erzwungen', static function (
$test->runCase('Peak kann die Tagesmindestlaufzeit ohne angebotene Sperre erzwingen', static function (
TestContext $test
) use (
$verbraucherId,
@@ -155,7 +181,13 @@ return static function (TestContext $test): void {
$anforderungSenden(0);
$test->waitUntil(static fn (): bool => GetValue($kontaktId) === false);
SetValue($rueckmeldungId, false);
$test->waitUntil(static fn (): bool => GetValue(
$test->objectByIdent('Schaltzustand', $verbraucherId)
) === false);
IPS_Sleep(2100);
$anforderungSenden(null, 'Peak');
IPS_SetProperty($verbraucherId, 'PeakSperreBeiMindestlaufzeitAnbieten', false);
IPS_SetProperty($verbraucherId, 'Mindestlaufzeit', 86400);
IPS_ApplyChanges($verbraucherId);
$test->waitUntil(static fn (): bool => GetValue($kontaktId) === true);
@@ -163,9 +195,14 @@ return static function (TestContext $test): void {
$test->objectByIdent('Sollleistung', $verbraucherId)
));
SetValue($rueckmeldungId, true);
$test->waitUntil(static fn (): bool => GetValue(
$test->objectByIdent('Schaltzustand', $verbraucherId)
) === true);
IPS_SetProperty($verbraucherId, 'Mindestlaufzeit', 0);
IPS_SetProperty($verbraucherId, 'PeakSperreBeiMindestlaufzeitAnbieten', true);
IPS_ApplyChanges($verbraucherId);
$anforderungSenden(null, 'PV');
});
$test->runCase('Physische Rückmeldung bestätigt und überwacht den Zustand', static function (
+32 -11
View File
@@ -38,11 +38,13 @@ return static function (TestContext $test): void {
'DiagnosevariablenAnzeigen' => true,
]
);
$lizenzcode = trim((string) getenv('ENELIX_TEST_LICENSE_CODE'));
$managerId = $test->createInstance(
'{6F771B18-59D4-4C8A-B951-3B2FE9F6A2C4}',
'Hilfsmanager',
[
'NetzleistungVariableID' => $netzleistungId,
'Lizenzcode' => $lizenzcode,
'AutomatischeSuche' => false,
'VerbraucherZuordnung' => json_encode([
['InstanzID' => $verbraucherId, 'Aktiv' => true],
@@ -88,6 +90,17 @@ return static function (TestContext $test): void {
IPS_RequestAction($managerId, 'Regeln', true);
};
if ($lizenzcode === '') {
$test->runCase('Manager bleibt ohne Lizenz sicher gesperrt', static function (
TestContext $test
) use ($verbraucherId, $managerId): void {
$test->assertInstanceStatus($verbraucherId);
$test->assertInstanceStatus($managerId, 203);
});
return;
}
$test->runCase('Instanzen und Diagnosevariablen werden angelegt', static function (
TestContext $test
) use ($verbraucherId, $managerId): void {
@@ -119,7 +132,7 @@ return static function (TestContext $test): void {
);
});
$test->runCase('Unter Minimum wird in PV und Peak bis über die Hysterese geheizt', static function (
$test->runCase('Unter Minimum erzwingt PV das Maximum und Peak bietet alle Stufen', static function (
TestContext $test
) use (
$temperaturSetzen,
@@ -132,17 +145,19 @@ return static function (TestContext $test): void {
$warteAufKontakte([false, false, true]);
$test->assertSame('PV', GetValue($test->objectByIdent('Betriebsart', $managerId)));
$managerRegeln(3000.0);
$warteAufKontakte([false, false, true]);
IPS_Sleep(1100);
$managerRegeln(1000.0);
$warteAufKontakte([false, true, false]);
$test->assertSame('Peak', GetValue($test->objectByIdent('Betriebsart', $managerId)));
$temperaturSetzen(49.9);
$managerRegeln(3000.0);
$warteAufKontakte([false, false, true]);
IPS_Sleep(1100);
$temperaturSetzen(47.0);
$managerRegeln(1000.0);
$warteAufKontakte([true, false, false]);
IPS_Sleep(1100);
$temperaturSetzen(50.0);
$managerRegeln(3000.0);
$managerRegeln(1000.0);
$warteAufKontakte([false, false, false]);
});
@@ -170,12 +185,12 @@ return static function (TestContext $test): void {
$managerRegeln(-3000.0);
$warteAufKontakte([false, false, false]);
$temperaturSetzen(55.0);
$temperaturSetzen(54.9);
$managerRegeln(-3000.0);
$warteAufKontakte([false, false, true]);
});
$test->runCase('Legionellenziel erlaubt Peak-Stufen und lädt in PV maximal', static function (
$test->runCase('Erhöhtes Maximalziel bleibt im freien Peakbetrieb gesperrt', static function (
TestContext $test
) use (
$verbraucherId,
@@ -190,10 +205,10 @@ return static function (TestContext $test): void {
IPS_Sleep(1100);
$managerRegeln(2000.0);
$warteAufKontakte([true, false, false]);
$warteAufKontakte([false, false, false]);
});
$test->runCase('Zeitplan erzwingt rechtzeitiges Aufheizen wie ein Minimum', static function (
$test->runCase('Zeitplan bietet in Peak alle Stufen und erzwingt in PV das Maximum', static function (
TestContext $test
) use (
$verbraucherId,
@@ -211,6 +226,12 @@ return static function (TestContext $test): void {
['Uhrzeit' => date('H:i', time() + 60), 'Solltemperatur' => 60],
], JSON_THROW_ON_ERROR));
IPS_ApplyChanges($verbraucherId);
$test->assertSame(true, GetValue(
$test->objectByIdent('AenderungMoeglich', $verbraucherId)
));
$warteAufKontakte([false, false, false]);
$managerRegeln(-3000.0);
$warteAufKontakte([false, false, true]);
});
};
+2 -2
View File
@@ -58,7 +58,7 @@ final class SymconTestContractTest extends TestCase
public function testAffectedModuleResolverSelectsAllMatches(): void
{
self::assertSame(
['Manager', 'VerbraucherEinStufig', 'Warmwassererwaermer'],
['LadestationStandAlone', 'Manager', 'Pufferspeicher', 'VerbraucherEinStufig', 'Warmwassererwaermer'],
$this->resolveAffectedModules(['libs/Nachrichtenvertrag.php'])
);
}
@@ -74,7 +74,7 @@ final class SymconTestContractTest extends TestCase
public function testAffectedModuleResolverSelectsAllModulesForFrameworkChanges(): void
{
self::assertSame(
['Manager', 'VerbraucherEinStufig', 'Warmwassererwaermer'],
['LadestationStandAlone', 'Manager', 'Pufferspeicher', 'VerbraucherEinStufig', 'Warmwassererwaermer'],
$this->resolveAffectedModules(['tests/Symcon/TestContext.php'])
);
}
@@ -16,23 +16,43 @@ final class EinStufigReglerTest extends TestCase
{
self::assertSame(
[0, 3500],
EinStufigRegler::leistungsangebot(3500, 0, true, true, false)
EinStufigRegler::leistungsangebot(3500, 0, true, true, false, false)
);
}
public function testGesperrtesOderErzwungenesAngebotEnthaeltNurIstleistung(): void
public function testPeakbetriebBietetBeiFreierSchaltungNurAus(): void
{
self::assertSame(
[0],
EinStufigRegler::leistungsangebot(3500, 0, true, true, false, true)
);
}
public function testTechnischGesperrtesAngebotEnthaeltNurIstleistung(): void
{
self::assertSame(
[3500],
EinStufigRegler::leistungsangebot(3500, 3500, true, false, false)
);
self::assertSame(
[0],
EinStufigRegler::leistungsangebot(3500, 0, true, true, true)
EinStufigRegler::leistungsangebot(3500, 3500, true, false, false, true)
);
self::assertSame(
[3500],
EinStufigRegler::leistungsangebot(3500, 3500, false, true, false)
EinStufigRegler::leistungsangebot(3500, 3500, false, true, false, true)
);
}
public function testPeakSperreBeiMindestlaufzeitIstKonfigurierbar(): void
{
self::assertSame(
[0, 3500],
EinStufigRegler::leistungsangebot(3500, 0, true, true, true, true, true)
);
self::assertSame(
[3500],
EinStufigRegler::leistungsangebot(3500, 0, true, true, true, true, false)
);
self::assertSame(
[3500],
EinStufigRegler::leistungsangebot(3500, 0, true, true, true, false, true)
);
}
@@ -40,7 +60,7 @@ final class EinStufigReglerTest extends TestCase
{
$this->expectException(InvalidArgumentException::class);
EinStufigRegler::leistungsangebot(3500, 1200, true, true, false);
EinStufigRegler::leistungsangebot(3500, 1200, true, true, false, false);
}
public function testFreigabeVerwendetZustandsabhaengigeMindestdauer(): void
@@ -65,6 +65,7 @@ final class VerbraucherEinStufigModulstrukturTest extends TestCase
'SchaltkontaktInvertiert',
'RueckmeldungVariableID',
'Mindestlaufzeit',
'PeakSperreBeiMindestlaufzeitAnbieten',
'Mindesteinschaltdauer',
'Mindestausschaltdauer',
'DiagnosevariablenAnzeigen',
@@ -130,7 +131,7 @@ final class VerbraucherEinStufigModulstrukturTest extends TestCase
);
}
public function testModulVerwendetAusschliesslichDenVertrag30(): void
public function testModulVerwendetVertrag40UndBetriebsartabhaengigesAngebot(): void
{
$modul = (string) file_get_contents(self::MODULPFAD . 'module.php');
@@ -138,6 +139,9 @@ final class VerbraucherEinStufigModulstrukturTest extends TestCase
self::assertStringContainsString('ManagerdatenEmpfangen(array $daten)', $modul);
self::assertStringContainsString('Nachrichtenvertrag::pruefeManagerdaten', $modul);
self::assertStringContainsString('Nachrichtenvertrag::pruefeVerbraucherdaten', $modul);
self::assertStringContainsString("['Betriebsart']", $modul);
self::assertStringContainsString('BETRIEBSART_PEAK', $modul);
self::assertStringContainsString("'Betriebsart' =>", $modul);
self::assertStringNotContainsString("RegisterVariableString('PowerSteps'", $modul);
self::assertStringNotContainsString("RegisterPropertyInteger('ManagerID'", $modul);
}
+40
View File
@@ -31,6 +31,46 @@ final class WarmwasserReglerTest extends TestCase
self::assertSame(0, WarmwasserRegler::stufeFuerLeistung($stufen, 0));
}
public function testPeakBietetUnterMinimumUndBeiAktiverUntererHystereseAlleStufen(): void
{
$werte = [0, 1000, 2000];
self::assertSame(
$werte,
WarmwasserRegler::leistungsangebot(true, 44.0, 50.0, 60.0, 5.0, false, $werte)
);
self::assertSame(
$werte,
WarmwasserRegler::leistungsangebot(true, 52.0, 50.0, 60.0, 5.0, true, $werte)
);
self::assertSame(
[0],
WarmwasserRegler::leistungsangebot(true, 52.0, 50.0, 60.0, 5.0, false, $werte)
);
}
public function testPvErzwingtUnterMinimumMaximumUndBietetSonstZustandsabhaengigAn(): void
{
$werte = [0, 1000, 2000];
self::assertSame(
[2000],
WarmwasserRegler::leistungsangebot(false, 44.0, 50.0, 60.0, 5.0, false, $werte)
);
self::assertSame(
$werte,
WarmwasserRegler::leistungsangebot(false, 52.0, 50.0, 60.0, 5.0, false, $werte)
);
self::assertSame(
$werte,
WarmwasserRegler::leistungsangebot(false, 58.0, 50.0, 60.0, 5.0, true, $werte)
);
self::assertSame(
[0],
WarmwasserRegler::leistungsangebot(false, 58.0, 50.0, 60.0, 5.0, false, $werte)
);
}
public function testDoppelteKontakteWerdenAbgewiesen(): void
{
$this->expectException(InvalidArgumentException::class);