Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1e6481330a | ||
|
|
dbd728dd03 |
@@ -206,6 +206,282 @@
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"type": "ExpansionPanel",
|
||||||
|
"caption": "Anlagentopologie",
|
||||||
|
"expanded": true,
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"type": "List",
|
||||||
|
"name": "AnlagenWechselrichter",
|
||||||
|
"caption": "Wechselrichter",
|
||||||
|
"rowCount": 4,
|
||||||
|
"add": true,
|
||||||
|
"delete": true,
|
||||||
|
"sortable": true,
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"caption": "ID",
|
||||||
|
"name": "ID",
|
||||||
|
"width": "130px",
|
||||||
|
"add": "wr-1",
|
||||||
|
"edit": {"type": "ValidationTextBox"}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"caption": "Name",
|
||||||
|
"name": "Name",
|
||||||
|
"width": "auto",
|
||||||
|
"add": "Wechselrichter",
|
||||||
|
"edit": {"type": "ValidationTextBox"}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"caption": "Typ",
|
||||||
|
"name": "Typ",
|
||||||
|
"width": "130px",
|
||||||
|
"add": "pv",
|
||||||
|
"edit": {
|
||||||
|
"type": "Select",
|
||||||
|
"options": [
|
||||||
|
{"caption": "PV", "value": "pv"},
|
||||||
|
{"caption": "Batterie", "value": "battery"},
|
||||||
|
{"caption": "Hybrid", "value": "hybrid"}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"caption": "AC Nennleistung",
|
||||||
|
"name": "ACNennleistung_kW",
|
||||||
|
"width": "150px",
|
||||||
|
"add": 0.0,
|
||||||
|
"edit": {
|
||||||
|
"type": "NumberSpinner",
|
||||||
|
"suffix": " kW",
|
||||||
|
"minimum": 0.0,
|
||||||
|
"digits": 3
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"caption": "Einspeisegrenze",
|
||||||
|
"name": "ACEinspeisegrenze_kW",
|
||||||
|
"width": "150px",
|
||||||
|
"add": 0.0,
|
||||||
|
"edit": {
|
||||||
|
"type": "NumberSpinner",
|
||||||
|
"suffix": " kW",
|
||||||
|
"minimum": 0.0,
|
||||||
|
"digits": 3
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"caption": "Bezugsgrenze",
|
||||||
|
"name": "ACBezugsgrenze_kW",
|
||||||
|
"width": "140px",
|
||||||
|
"add": 0.0,
|
||||||
|
"edit": {
|
||||||
|
"type": "NumberSpinner",
|
||||||
|
"suffix": " kW",
|
||||||
|
"minimum": 0.0,
|
||||||
|
"digits": 3
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "List",
|
||||||
|
"name": "AnlagenPVFlaechen",
|
||||||
|
"caption": "PV-Flaechen",
|
||||||
|
"rowCount": 6,
|
||||||
|
"add": true,
|
||||||
|
"delete": true,
|
||||||
|
"sortable": true,
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"caption": "ID",
|
||||||
|
"name": "ID",
|
||||||
|
"width": "120px",
|
||||||
|
"add": "pv-1",
|
||||||
|
"edit": {"type": "ValidationTextBox"}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"caption": "Name",
|
||||||
|
"name": "Name",
|
||||||
|
"width": "auto",
|
||||||
|
"add": "PV-Flaeche",
|
||||||
|
"edit": {"type": "ValidationTextBox"}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"caption": "DC-Leistung",
|
||||||
|
"name": "DCLeistung_kWp",
|
||||||
|
"width": "130px",
|
||||||
|
"add": 0.0,
|
||||||
|
"edit": {
|
||||||
|
"type": "NumberSpinner",
|
||||||
|
"suffix": " kWp",
|
||||||
|
"minimum": 0.0,
|
||||||
|
"digits": 3
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"caption": "Neigung",
|
||||||
|
"name": "Neigung_Grad",
|
||||||
|
"width": "110px",
|
||||||
|
"add": 30.0,
|
||||||
|
"edit": {
|
||||||
|
"type": "NumberSpinner",
|
||||||
|
"suffix": " Grad",
|
||||||
|
"minimum": 0.0,
|
||||||
|
"maximum": 90.0,
|
||||||
|
"digits": 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"caption": "Azimut",
|
||||||
|
"name": "Azimut_Grad",
|
||||||
|
"width": "110px",
|
||||||
|
"add": 0.0,
|
||||||
|
"edit": {
|
||||||
|
"type": "NumberSpinner",
|
||||||
|
"suffix": " Grad",
|
||||||
|
"minimum": -180.0,
|
||||||
|
"maximum": 180.0,
|
||||||
|
"digits": 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"caption": "Wechselrichter-ID",
|
||||||
|
"name": "WechselrichterID",
|
||||||
|
"width": "150px",
|
||||||
|
"add": "wr-1",
|
||||||
|
"edit": {"type": "ValidationTextBox"}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"caption": "MPPT",
|
||||||
|
"name": "MPPT",
|
||||||
|
"width": "90px",
|
||||||
|
"add": "",
|
||||||
|
"edit": {"type": "ValidationTextBox"}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"caption": "Module",
|
||||||
|
"name": "Modulanzahl",
|
||||||
|
"width": "90px",
|
||||||
|
"add": 0,
|
||||||
|
"edit": {
|
||||||
|
"type": "NumberSpinner",
|
||||||
|
"minimum": 0,
|
||||||
|
"digits": 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"caption": "Modulleistung",
|
||||||
|
"name": "Modulleistung_Wp",
|
||||||
|
"width": "130px",
|
||||||
|
"add": 0.0,
|
||||||
|
"edit": {
|
||||||
|
"type": "NumberSpinner",
|
||||||
|
"suffix": " Wp",
|
||||||
|
"minimum": 0.0,
|
||||||
|
"digits": 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "List",
|
||||||
|
"name": "AnlagenBatterien",
|
||||||
|
"caption": "Batteriespeicher",
|
||||||
|
"rowCount": 4,
|
||||||
|
"add": true,
|
||||||
|
"delete": true,
|
||||||
|
"sortable": true,
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"caption": "ID",
|
||||||
|
"name": "ID",
|
||||||
|
"width": "120px",
|
||||||
|
"add": "bat-1",
|
||||||
|
"edit": {"type": "ValidationTextBox"}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"caption": "Name",
|
||||||
|
"name": "Name",
|
||||||
|
"width": "auto",
|
||||||
|
"add": "Batteriespeicher",
|
||||||
|
"edit": {"type": "ValidationTextBox"}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"caption": "Nennkapazitaet",
|
||||||
|
"name": "Nennkapazitaet_kWh",
|
||||||
|
"width": "150px",
|
||||||
|
"add": 0.0,
|
||||||
|
"edit": {
|
||||||
|
"type": "NumberSpinner",
|
||||||
|
"suffix": " kWh",
|
||||||
|
"minimum": 0.0,
|
||||||
|
"digits": 3
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"caption": "Nutzkapazitaet",
|
||||||
|
"name": "Nutzkapazitaet_kWh",
|
||||||
|
"width": "150px",
|
||||||
|
"add": 0.0,
|
||||||
|
"edit": {
|
||||||
|
"type": "NumberSpinner",
|
||||||
|
"suffix": " kWh",
|
||||||
|
"minimum": 0.0,
|
||||||
|
"digits": 3
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"caption": "Max. Laden",
|
||||||
|
"name": "MaxLadeleistung_kW",
|
||||||
|
"width": "130px",
|
||||||
|
"add": 0.0,
|
||||||
|
"edit": {
|
||||||
|
"type": "NumberSpinner",
|
||||||
|
"suffix": " kW",
|
||||||
|
"minimum": 0.0,
|
||||||
|
"digits": 3
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"caption": "Max. Entladen",
|
||||||
|
"name": "MaxEntladeleistung_kW",
|
||||||
|
"width": "140px",
|
||||||
|
"add": 0.0,
|
||||||
|
"edit": {
|
||||||
|
"type": "NumberSpinner",
|
||||||
|
"suffix": " kW",
|
||||||
|
"minimum": 0.0,
|
||||||
|
"digits": 3
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"caption": "Wechselrichter-ID",
|
||||||
|
"name": "WechselrichterID",
|
||||||
|
"width": "150px",
|
||||||
|
"add": "wr-1",
|
||||||
|
"edit": {"type": "ValidationTextBox"}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"caption": "Kopplung",
|
||||||
|
"name": "Kopplung",
|
||||||
|
"width": "130px",
|
||||||
|
"add": "ac",
|
||||||
|
"edit": {
|
||||||
|
"type": "Select",
|
||||||
|
"options": [
|
||||||
|
{"caption": "AC", "value": "ac"},
|
||||||
|
{"caption": "DC", "value": "dc"},
|
||||||
|
{"caption": "Hybrid", "value": "hybrid"}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"type": "ExpansionPanel",
|
"type": "ExpansionPanel",
|
||||||
"caption": "Erweiterte Einstellungen",
|
"caption": "Erweiterte Einstellungen",
|
||||||
|
|||||||
@@ -6,7 +6,9 @@ require_once __DIR__ . '/../libs/ManagerSchnittstelle.php';
|
|||||||
require_once __DIR__ . '/../libs/Nachrichtenvertrag.php';
|
require_once __DIR__ . '/../libs/Nachrichtenvertrag.php';
|
||||||
require_once __DIR__ . '/../libs/ManagerRegler.php';
|
require_once __DIR__ . '/../libs/ManagerRegler.php';
|
||||||
require_once __DIR__ . '/../libs/Lizenzpruefung.php';
|
require_once __DIR__ . '/../libs/Lizenzpruefung.php';
|
||||||
|
require_once __DIR__ . '/../libs/Anlagentopologie.php';
|
||||||
|
|
||||||
|
use Belevo\EnelixEMS\Anlagentopologie;
|
||||||
use Belevo\EnelixEMS\Lizenzpruefung;
|
use Belevo\EnelixEMS\Lizenzpruefung;
|
||||||
use Belevo\EnelixEMS\ManagerRegler;
|
use Belevo\EnelixEMS\ManagerRegler;
|
||||||
use Belevo\EnelixEMS\ManagerSchnittstelle;
|
use Belevo\EnelixEMS\ManagerSchnittstelle;
|
||||||
@@ -19,6 +21,7 @@ class Manager extends IPSModule implements ManagerSchnittstelle
|
|||||||
private const STATUS_KONFIGURATION_UNGUELTIG = 202;
|
private const STATUS_KONFIGURATION_UNGUELTIG = 202;
|
||||||
private const STATUS_LIZENZ_UNGUELTIG = 203;
|
private const STATUS_LIZENZ_UNGUELTIG = 203;
|
||||||
private const LIZENZ_ENDPOINT = 'https://license.enelix.ch/api/v1/licenses/activate';
|
private const LIZENZ_ENDPOINT = 'https://license.enelix.ch/api/v1/licenses/activate';
|
||||||
|
private const PROGNOSE_TOPOLOGIE_ENDPOINT = 'https://license.enelix.ch/api/v1/installations/%s/prognosis/topology';
|
||||||
private const LIZENZ_RETRY_SEKUNDEN = 3600;
|
private const LIZENZ_RETRY_SEKUNDEN = 3600;
|
||||||
private const VM_UPDATE = 10603;
|
private const VM_UPDATE = 10603;
|
||||||
private const VERBRAUCHER_LIZENZEN = [
|
private const VERBRAUCHER_LIZENZEN = [
|
||||||
@@ -91,6 +94,9 @@ class Manager extends IPSModule implements ManagerSchnittstelle
|
|||||||
$this->RegisterPropertyString('Monatsgrenzen', $this->standardMonatsgrenzen());
|
$this->RegisterPropertyString('Monatsgrenzen', $this->standardMonatsgrenzen());
|
||||||
$this->RegisterPropertyFloat('SollwertSolarladen', 0.0);
|
$this->RegisterPropertyFloat('SollwertSolarladen', 0.0);
|
||||||
$this->RegisterPropertyFloat('Umschaltdifferenz', 5.0);
|
$this->RegisterPropertyFloat('Umschaltdifferenz', 5.0);
|
||||||
|
$this->RegisterPropertyString('AnlagenWechselrichter', '[]');
|
||||||
|
$this->RegisterPropertyString('AnlagenPVFlaechen', '[]');
|
||||||
|
$this->RegisterPropertyString('AnlagenBatterien', '[]');
|
||||||
$this->RegisterPropertyString('PrognoseAnschluss', '{}');
|
$this->RegisterPropertyString('PrognoseAnschluss', '{}');
|
||||||
$this->RegisterPropertyString('SDLAnschluss', '{}');
|
$this->RegisterPropertyString('SDLAnschluss', '{}');
|
||||||
$this->RegisterPropertyString('Lizenzcode', '');
|
$this->RegisterPropertyString('Lizenzcode', '');
|
||||||
@@ -114,6 +120,7 @@ class Manager extends IPSModule implements ManagerSchnittstelle
|
|||||||
$this->RegisterAttributeInteger('LizenzNaechsterVersuch', 0);
|
$this->RegisterAttributeInteger('LizenzNaechsterVersuch', 0);
|
||||||
$this->RegisterAttributeInteger('LizenzBerechtigungenNaechsterVersuch', 0);
|
$this->RegisterAttributeInteger('LizenzBerechtigungenNaechsterVersuch', 0);
|
||||||
$this->RegisterAttributeString('LizenzLetzterFehler', '');
|
$this->RegisterAttributeString('LizenzLetzterFehler', '');
|
||||||
|
$this->RegisterAttributeString('PrognoseInstallationsToken', '');
|
||||||
|
|
||||||
// Die alten Timer bleiben deaktiviert registriert, damit bestehende Instanzen sauber migrieren.
|
// Die alten Timer bleiben deaktiviert registriert, damit bestehende Instanzen sauber migrieren.
|
||||||
$this->RegisterTimer('Regelzyklus', 0, '');
|
$this->RegisterTimer('Regelzyklus', 0, '');
|
||||||
@@ -163,6 +170,7 @@ class Manager extends IPSModule implements ManagerSchnittstelle
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
$this->SetStatus(self::STATUS_AKTIV);
|
$this->SetStatus(self::STATUS_AKTIV);
|
||||||
|
$this->synchronisiereAnlagentopologie();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function GetConfigurationForm(): string
|
public function GetConfigurationForm(): string
|
||||||
@@ -253,6 +261,14 @@ class Manager extends IPSModule implements ManagerSchnittstelle
|
|||||||
return json_encode($formular, JSON_THROW_ON_ERROR);
|
return json_encode($formular, JSON_THROW_ON_ERROR);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function AnlagentopologieExportieren(): string
|
||||||
|
{
|
||||||
|
return json_encode(
|
||||||
|
$this->leseAnlagentopologie(),
|
||||||
|
JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
public function MessageSink($zeitstempel, $senderID, $nachricht, $daten): void
|
public function MessageSink($zeitstempel, $senderID, $nachricht, $daten): void
|
||||||
{
|
{
|
||||||
if ((int) $senderID === $this->ReadPropertyInteger('NetzleistungVariableID')
|
if ((int) $senderID === $this->ReadPropertyInteger('NetzleistungVariableID')
|
||||||
@@ -1065,6 +1081,9 @@ class Manager extends IPSModule implements ManagerSchnittstelle
|
|||||||
$installationID = $this->initialisiereLizenzInstallationID();
|
$installationID = $this->initialisiereLizenzInstallationID();
|
||||||
$codeHash = hash('sha256', $normalisiert);
|
$codeHash = hash('sha256', $normalisiert);
|
||||||
$gespeicherterHash = $this->ReadAttributeString('LizenzcodeHash');
|
$gespeicherterHash = $this->ReadAttributeString('LizenzcodeHash');
|
||||||
|
if ($gespeicherterHash !== '' && !hash_equals($gespeicherterHash, $codeHash)) {
|
||||||
|
$this->WriteAttributeString('PrognoseInstallationsToken', '');
|
||||||
|
}
|
||||||
$lease = $this->leseLizenzLease();
|
$lease = $this->leseLizenzLease();
|
||||||
$codePasst = $gespeicherterHash !== '' && hash_equals($gespeicherterHash, $codeHash);
|
$codePasst = $gespeicherterHash !== '' && hash_equals($gespeicherterHash, $codeHash);
|
||||||
$lastspitzenmodus = $this->ReadPropertyInteger('Lastspitzenmodus');
|
$lastspitzenmodus = $this->ReadPropertyInteger('Lastspitzenmodus');
|
||||||
@@ -1080,7 +1099,10 @@ class Manager extends IPSModule implements ManagerSchnittstelle
|
|||||||
);
|
);
|
||||||
$berechtigungenVeraltet = $basisGueltig
|
$berechtigungenVeraltet = $basisGueltig
|
||||||
&& !$this->lizenzBerechtigungenPassen($lease, $lastspitzenmodus);
|
&& !$this->lizenzBerechtigungenPassen($lease, $lastspitzenmodus);
|
||||||
|
$geraetezugangFehlt = $this->hatAnlagentopologie()
|
||||||
|
&& $this->ReadAttributeString('PrognoseInstallationsToken') === '';
|
||||||
$routineFaellig = (!$gueltig && !$berechtigungenVeraltet)
|
$routineFaellig = (!$gueltig && !$berechtigungenVeraltet)
|
||||||
|
|| $geraetezugangFehlt
|
||||||
|| Lizenzpruefung::erneuerungFaellig($lease);
|
|| Lizenzpruefung::erneuerungFaellig($lease);
|
||||||
$jetzt = time();
|
$jetzt = time();
|
||||||
|
|
||||||
@@ -1122,11 +1144,22 @@ class Manager extends IPSModule implements ManagerSchnittstelle
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
$neueLease = $this->rufeLizenzserverAuf($normalisiert, $installationID);
|
$neueLease = $this->rufeLizenzserverAuf($normalisiert, $installationID);
|
||||||
|
$geraeteToken = $neueLease['deviceToken'] ?? null;
|
||||||
|
unset($neueLease['deviceToken']);
|
||||||
|
if ($geraetezugangFehlt
|
||||||
|
&& (!is_string($geraeteToken)
|
||||||
|
|| preg_match('/^[A-Za-z0-9_-]{43,128}$/', $geraeteToken) !== 1)
|
||||||
|
) {
|
||||||
|
throw new RuntimeException('Der Lizenzserver lieferte keinen gueltigen Geraetezugang.');
|
||||||
|
}
|
||||||
Lizenzpruefung::pruefeLease(
|
Lizenzpruefung::pruefeLease(
|
||||||
$neueLease,
|
$neueLease,
|
||||||
$installationID,
|
$installationID,
|
||||||
$this->ReadPropertyInteger('Lastspitzenmodus')
|
$this->ReadPropertyInteger('Lastspitzenmodus')
|
||||||
);
|
);
|
||||||
|
if ($geraetezugangFehlt) {
|
||||||
|
$this->WriteAttributeString('PrognoseInstallationsToken', $geraeteToken);
|
||||||
|
}
|
||||||
$this->WriteAttributeString(
|
$this->WriteAttributeString(
|
||||||
'LizenzLease',
|
'LizenzLease',
|
||||||
json_encode($neueLease, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES)
|
json_encode($neueLease, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES)
|
||||||
@@ -1321,6 +1354,8 @@ class Manager extends IPSModule implements ManagerSchnittstelle
|
|||||||
$nutzlast = json_encode([
|
$nutzlast = json_encode([
|
||||||
'code' => $lizenzcode,
|
'code' => $lizenzcode,
|
||||||
'installationId' => $installationID,
|
'installationId' => $installationID,
|
||||||
|
'issueDeviceToken' => $this->hatAnlagentopologie()
|
||||||
|
&& $this->ReadAttributeString('PrognoseInstallationsToken') === '',
|
||||||
], JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES);
|
], JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES);
|
||||||
$curl = curl_init(self::LIZENZ_ENDPOINT);
|
$curl = curl_init(self::LIZENZ_ENDPOINT);
|
||||||
if ($curl === false) {
|
if ($curl === false) {
|
||||||
@@ -1415,6 +1450,7 @@ class Manager extends IPSModule implements ManagerSchnittstelle
|
|||||||
$this->leseAktiveVerbraucherIDs('VerbraucherZuordnung');
|
$this->leseAktiveVerbraucherIDs('VerbraucherZuordnung');
|
||||||
$this->leseAktiveVerbraucherIDs('AutomatischeVerbraucherZuordnung');
|
$this->leseAktiveVerbraucherIDs('AutomatischeVerbraucherZuordnung');
|
||||||
$this->dekodiereMonatsgrenzen($this->ReadPropertyString('Monatsgrenzen'));
|
$this->dekodiereMonatsgrenzen($this->ReadPropertyString('Monatsgrenzen'));
|
||||||
|
$this->leseAnlagentopologie();
|
||||||
foreach (['PrognoseAnschluss', 'SDLAnschluss', 'StoermeldeAnschluss'] as $property) {
|
foreach (['PrognoseAnschluss', 'SDLAnschluss', 'StoermeldeAnschluss'] as $property) {
|
||||||
$wert = json_decode($this->ReadPropertyString($property), true, 512, JSON_THROW_ON_ERROR);
|
$wert = json_decode($this->ReadPropertyString($property), true, 512, JSON_THROW_ON_ERROR);
|
||||||
if (!is_array($wert)) {
|
if (!is_array($wert)) {
|
||||||
@@ -1423,6 +1459,114 @@ class Manager extends IPSModule implements ManagerSchnittstelle
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @return array<string, mixed> */
|
||||||
|
private function leseAnlagentopologie(): array
|
||||||
|
{
|
||||||
|
return Anlagentopologie::ausJson(
|
||||||
|
$this->ReadPropertyString('AnlagenWechselrichter'),
|
||||||
|
$this->ReadPropertyString('AnlagenPVFlaechen'),
|
||||||
|
$this->ReadPropertyString('AnlagenBatterien'),
|
||||||
|
$this->ReadAttributeString('LizenzInstallationID')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function hatAnlagentopologie(): bool
|
||||||
|
{
|
||||||
|
$topologie = $this->leseAnlagentopologie();
|
||||||
|
|
||||||
|
return $topologie['inverters'] !== []
|
||||||
|
|| $topologie['pvArrays'] !== []
|
||||||
|
|| $topologie['batteries'] !== [];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function synchronisiereAnlagentopologie(): void
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$topologie = $this->leseAnlagentopologie();
|
||||||
|
if ($topologie['inverters'] === []
|
||||||
|
&& $topologie['pvArrays'] === []
|
||||||
|
&& $topologie['batteries'] === []
|
||||||
|
) {
|
||||||
|
$this->setzeDiagnosewert('Prognosestatus', 'NichtVerwendet');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$token = trim($this->ReadAttributeString('PrognoseInstallationsToken'));
|
||||||
|
if ($token === '') {
|
||||||
|
throw new RuntimeException('Der Geraetezugang fuer die Prognose fehlt.');
|
||||||
|
}
|
||||||
|
$this->sendeAnlagentopologie($topologie, $token);
|
||||||
|
$this->setzeDiagnosewert('Prognosestatus', 'Verbunden');
|
||||||
|
} catch (Throwable $fehler) {
|
||||||
|
$meldung = substr($fehler->getMessage(), 0, 500);
|
||||||
|
$this->setzeDiagnosewert('Prognosestatus', 'Fehler: ' . $meldung);
|
||||||
|
$this->protokolliere('Prognosesynchronisation', $meldung);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param array<string, mixed> $topologie */
|
||||||
|
private function sendeAnlagentopologie(array $topologie, string $token): void
|
||||||
|
{
|
||||||
|
$url = sprintf(
|
||||||
|
self::PROGNOSE_TOPOLOGIE_ENDPOINT,
|
||||||
|
rawurlencode($this->ReadAttributeString('LizenzInstallationID'))
|
||||||
|
);
|
||||||
|
$nutzlast = json_encode(
|
||||||
|
$topologie,
|
||||||
|
JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES
|
||||||
|
);
|
||||||
|
$curl = curl_init($url);
|
||||||
|
if ($curl === false) {
|
||||||
|
throw new RuntimeException('Die Prognosesynchronisation konnte nicht vorbereitet werden.');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
curl_setopt_array($curl, [
|
||||||
|
CURLOPT_CUSTOMREQUEST => 'PUT',
|
||||||
|
CURLOPT_POSTFIELDS => $nutzlast,
|
||||||
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
|
CURLOPT_CONNECTTIMEOUT => 5,
|
||||||
|
CURLOPT_TIMEOUT => 10,
|
||||||
|
CURLOPT_HTTPHEADER => [
|
||||||
|
'Accept: application/json',
|
||||||
|
'Authorization: Bearer ' . $token,
|
||||||
|
'Content-Type: application/json',
|
||||||
|
],
|
||||||
|
CURLOPT_USERAGENT => 'Enelix-EMS-Manager/0.2',
|
||||||
|
]);
|
||||||
|
$antwort = curl_exec($curl);
|
||||||
|
if ($antwort === false) {
|
||||||
|
throw new RuntimeException(
|
||||||
|
'Prognosedienst nicht erreichbar: ' . curl_error($curl)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (strlen($antwort) > 65536) {
|
||||||
|
throw new RuntimeException('Die Antwort der Prognosesynchronisation ist zu gross.');
|
||||||
|
}
|
||||||
|
$httpStatus = (int) curl_getinfo($curl, CURLINFO_HTTP_CODE);
|
||||||
|
} finally {
|
||||||
|
curl_close($curl);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($httpStatus === 401 || $httpStatus === 403) {
|
||||||
|
$this->WriteAttributeString('PrognoseInstallationsToken', '');
|
||||||
|
}
|
||||||
|
if ($httpStatus !== 200) {
|
||||||
|
$meldung = 'Unbekannter Fehler';
|
||||||
|
try {
|
||||||
|
$daten = json_decode($antwort, true, 512, JSON_THROW_ON_ERROR);
|
||||||
|
if (is_array($daten) && is_string($daten['error'] ?? null)) {
|
||||||
|
$meldung = $daten['error'];
|
||||||
|
}
|
||||||
|
} catch (Throwable $fehler) {
|
||||||
|
// Der HTTP-Status bleibt die fuehrende Fehlerinformation.
|
||||||
|
}
|
||||||
|
throw new RuntimeException(
|
||||||
|
'Prognosesynchronisation HTTP ' . $httpStatus . ': ' . $meldung
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** @return array<int, float> */
|
/** @return array<int, float> */
|
||||||
private function dekodiereMonatsgrenzen(string $json): array
|
private function dekodiereMonatsgrenzen(string $json): array
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -102,6 +102,7 @@ der Uebernahme in einen Freigabebranch erfolgreich sein.
|
|||||||
- [Manager-Verbraucher-Schnittstelle](docs/Schnittstelle.md)
|
- [Manager-Verbraucher-Schnittstelle](docs/Schnittstelle.md)
|
||||||
- [Batterieschnittstelle](docs/Schnittstelle-Batterie.md)
|
- [Batterieschnittstelle](docs/Schnittstelle-Batterie.md)
|
||||||
- [ADR 0004: Betriebsartabhaengige Leistungsangebote](docs/adr/0004-betriebsartabhaengige-leistungsangebote.md)
|
- [ADR 0004: Betriebsartabhaengige Leistungsangebote](docs/adr/0004-betriebsartabhaengige-leistungsangebote.md)
|
||||||
|
- [ADR 0005: Anlagentopologie fuer Prognosen](docs/adr/0005-anlagentopologie-fuer-prognosen.md)
|
||||||
- [Manager-Modul inklusive Lizenzierung](docs/module/Manager/README.md)
|
- [Manager-Modul inklusive Lizenzierung](docs/module/Manager/README.md)
|
||||||
- [Warmwassererwaermer-Modul](docs/module/Wassererwaermer/README.md)
|
- [Warmwassererwaermer-Modul](docs/module/Wassererwaermer/README.md)
|
||||||
- [Pufferspeicher-Modul](docs/module/Pufferspeicher/README.md)
|
- [Pufferspeicher-Modul](docs/module/Pufferspeicher/README.md)
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
# ADR 0005: Anlagentopologie als Manager-Stammdaten
|
||||||
|
|
||||||
|
## Kontext
|
||||||
|
|
||||||
|
Die Prognose benoetigt technische Stammdaten zu PV-Flaechen,
|
||||||
|
Wechselrichtern und Batteriespeichern. Diese Daten beschreiben die reale
|
||||||
|
Installation und werden auch von der lokalen Regelung benoetigt. Eine
|
||||||
|
unabhaengige Pflege im Prognoseportal wuerde zwei konkurrierende Wahrheiten
|
||||||
|
erzeugen.
|
||||||
|
|
||||||
|
Hybridwechselrichter benoetigen eine ausdrueckliche Topologie. PV und Batterie
|
||||||
|
duerfen nicht als zwei unabhaengige AC-Quellen mit jeweils voller
|
||||||
|
Wechselrichterleistung behandelt werden.
|
||||||
|
|
||||||
|
## Entscheidung
|
||||||
|
|
||||||
|
Der Enelix-Manager ist die fuehrende Quelle fuer die technische
|
||||||
|
Anlagentopologie. Er speichert drei getrennte Listen:
|
||||||
|
|
||||||
|
- Wechselrichter mit Typ, AC-Nennleistung und optionalen AC-Grenzen,
|
||||||
|
- PV-Flaechen mit DC-Leistung, Ausrichtung und Wechselrichterbezug,
|
||||||
|
- Batteriespeicher mit Kapazitaeten, Leistungen, Kopplung und
|
||||||
|
Wechselrichterbezug.
|
||||||
|
|
||||||
|
Jede Komponente besitzt eine innerhalb ihrer Liste eindeutige, stabile ID.
|
||||||
|
PV-Flaechen und Batterien referenzieren einen Wechselrichter ueber diese ID.
|
||||||
|
|
||||||
|
Die AC-Nennleistung gehoert zum Wechselrichter. Die DC-Leistung gehoert zur
|
||||||
|
PV-Flaeche. Modulanzahl und Modulleistung sind optionale Detailangaben und
|
||||||
|
muessen gemeinsam gepflegt werden.
|
||||||
|
|
||||||
|
Bei einem Hybridwechselrichter bleiben PV-Flaechen und Batterie getrennte
|
||||||
|
Komponenten. Der Exportvertrag liefert zusaetzlich eine gemeinsame AC-Grenze,
|
||||||
|
die beide Seiten demselben Wechselrichter zuordnet. Damit darf ihre kombinierte
|
||||||
|
AC-Leistung die Wechselrichtergrenze nicht unabhaengig mehrfach ausschoepfen.
|
||||||
|
|
||||||
|
Der Manager exportiert die normalisierte Struktur mit Vertragsversion '1.0'
|
||||||
|
und seiner stabilen Lizenz-Installations-ID. Eine aggregierte Zusammenfassung
|
||||||
|
erleichtert die schrittweise Anbindung bestehender Prognoseberechnungen.
|
||||||
|
|
||||||
|
Tarife, Prognosevarianten und rein prognosespezifische Annahmen bleiben im
|
||||||
|
Prognoseportal. Dort werden die vom Manager gelieferten technischen Stammdaten
|
||||||
|
standardmaessig nur angezeigt.
|
||||||
|
|
||||||
|
## Alternativen
|
||||||
|
|
||||||
|
### Vollstaendige Pflege im Prognoseportal
|
||||||
|
|
||||||
|
Diese Variante waere fuer den Prognosedienst einfach, erzeugt aber
|
||||||
|
Doppelpflege und kann von der lokal tatsaechlich installierten Anlage
|
||||||
|
abweichen.
|
||||||
|
|
||||||
|
### Batterie und PV als ein Hybridobjekt speichern
|
||||||
|
|
||||||
|
Ein einzelnes Objekt waere kompakt, bildet mehrere PV-Flaechen, MPPT-Eingaenge
|
||||||
|
und spaetere Erweiterungen jedoch schlecht ab. Ausserdem gingen die getrennten
|
||||||
|
Kapazitaets- und Leistungsgrenzen verloren.
|
||||||
|
|
||||||
|
### AC- und DC-Leistung an jeder PV-Flaeche speichern
|
||||||
|
|
||||||
|
Das wuerde bei mehreren Flaechen an einem Wechselrichter die AC-Leistung
|
||||||
|
mehrfach zaehlen. Deshalb wird AC am Wechselrichter und DC an der Flaeche
|
||||||
|
gespeichert.
|
||||||
|
|
||||||
|
## Folgen
|
||||||
|
|
||||||
|
- Bestehende Installationen bleiben mit drei leeren Listen gueltig.
|
||||||
|
- Fehlerhafte Referenzen oder unplausible technische Grenzen sperren die
|
||||||
|
Manager-Konfiguration kontrolliert.
|
||||||
|
- Ein Hybridwechselrichter kann mehrere PV-Flaechen und Batteriespeicher
|
||||||
|
verbinden, besitzt aber nur eine gemeinsame AC-Nennleistung.
|
||||||
|
- Das Prognoseportal muss die technischen Stammdaten aus dem Managervertrag
|
||||||
|
lesen und eigene Eingaben dafuer als schreibgeschuetzt behandeln.
|
||||||
|
- Fuer den automatischen Upload ist ein eigener, widerrufbarer
|
||||||
|
Installationszugang erforderlich. Der Lizenzcode selbst wird nicht als
|
||||||
|
dauerhaftes API-Secret verwendet.
|
||||||
@@ -47,6 +47,9 @@ Ohne angelegte Diagnosevariablen existieren nur `Aktiv`, `Betriebsart` und `Netz
|
|||||||
| `Monatsgrenzen` | String/JSON | Editierbare Liste mit zwölf Monatswerten in W. |
|
| `Monatsgrenzen` | String/JSON | Editierbare Liste mit zwölf Monatswerten in W. |
|
||||||
| `SollwertSolarladen` | Float | `0` W; gewünschte Netzleistung im Solarladebetrieb. |
|
| `SollwertSolarladen` | Float | `0` W; gewünschte Netzleistung im Solarladebetrieb. |
|
||||||
| `Umschaltdifferenz` | Float | `5` %; Mindestdifferenz der berechneten Sollleistungen vor Umschaltung. |
|
| `Umschaltdifferenz` | Float | `5` %; Mindestdifferenz der berechneten Sollleistungen vor Umschaltung. |
|
||||||
|
| `AnlagenWechselrichter` | String/JSON | `[]`; Wechselrichter mit Typ und AC-Grenzen. |
|
||||||
|
| `AnlagenPVFlaechen` | String/JSON | `[]`; PV-Flaechen mit DC-Leistung, Ausrichtung und Wechselrichter-ID. |
|
||||||
|
| `AnlagenBatterien` | String/JSON | `[]`; Batteriespeicher mit Kapazitaeten, Leistungen, Kopplung und Wechselrichter-ID. |
|
||||||
| `PrognoseAnschluss` | String/JSON | Optionaler Forecast-Anschluss. |
|
| `PrognoseAnschluss` | String/JSON | Optionaler Forecast-Anschluss. |
|
||||||
| `SDLAnschluss` | String/JSON | Optionaler SDL/VGT-Anschluss. |
|
| `SDLAnschluss` | String/JSON | Optionaler SDL/VGT-Anschluss. |
|
||||||
| `Lizenzcode` | String | Im Enelix-Lizenzportal erworbener Aktivierungscode. |
|
| `Lizenzcode` | String | Im Enelix-Lizenzportal erworbener Aktivierungscode. |
|
||||||
@@ -60,6 +63,33 @@ Die Monatsgrenzen werden in den Grundeinstellungen über eine Schaltfläche
|
|||||||
ein- und ausgeblendet. Die automatische Verbrauchersuche kann dort erneut
|
ein- und ausgeblendet. Die automatische Verbrauchersuche kann dort erneut
|
||||||
ausgeführt werden, ohne andere ungespeicherte Formulareingaben zu verlieren.
|
ausgeführt werden, ohne andere ungespeicherte Formulareingaben zu verlieren.
|
||||||
|
|
||||||
|
## Anlagentopologie und Prognoseexport
|
||||||
|
|
||||||
|
Der Manager ist die fuehrende Quelle fuer die technischen Stammdaten. Die
|
||||||
|
Konfiguration trennt Wechselrichter, PV-Flaechen und Batteriespeicher. AC-Leistung
|
||||||
|
wird am Wechselrichter, DC-Leistung an der PV-Flaeche gepflegt. Modulanzahl und
|
||||||
|
Modulleistung sind optional und muessen gemeinsam gesetzt werden.
|
||||||
|
|
||||||
|
PV-Flaechen und Batterien referenzieren ihren Wechselrichter ueber dessen stabile
|
||||||
|
ID. Bei `hybrid` koennen beide denselben Wechselrichter verwenden. Der Export
|
||||||
|
`ENELIX_AnlagentopologieExportieren($InstanzID)` weist dann eine gemeinsame
|
||||||
|
AC-Grenze aus, damit PV und Batterie die Nennleistung nicht unabhaengig doppelt
|
||||||
|
beanspruchen.
|
||||||
|
|
||||||
|
Der JSON-Export enthaelt die Vertragsversion `1.0`, die Lizenz-Installations-ID,
|
||||||
|
die drei Komponentenlisten, gemeinsame AC-Grenzen und aggregierte Summen fuer
|
||||||
|
die schrittweise Prognoseanbindung. Tarif- und Variantenparameter bleiben im
|
||||||
|
Prognoseportal. Eine nichtleere Topologie wird beim Speichern automatisch ueber
|
||||||
|
einen separaten, widerrufbaren Installationszugang synchronisiert. Der
|
||||||
|
Lizenzserver liefert diesen Zugang nur auf ausdrueckliche Geraeteanforderung;
|
||||||
|
der Manager entfernt ihn vor dem Speichern aus der Lease und haelt ihn in einem
|
||||||
|
internen Attribut. Der Lizenzcode wird nicht als API-Token verwendet. Ein
|
||||||
|
Synchronisationsfehler erscheint im `Prognosestatus`, blockiert die lokale
|
||||||
|
EMS-Regelung aber nicht.
|
||||||
|
|
||||||
|
Die Entscheidung und ihre Alternativen sind in
|
||||||
|
[ADR 0005](../../adr/0005-anlagentopologie-fuer-prognosen.md) dokumentiert.
|
||||||
|
|
||||||
## Lizenzierung
|
## Lizenzierung
|
||||||
|
|
||||||
Der Manager arbeitet nur mit einer gueltigen Manager-Lizenz. Das Lizenzfeld steht
|
Der Manager arbeitet nur mit einer gueltigen Manager-Lizenz. Das Lizenzfeld steht
|
||||||
|
|||||||
@@ -0,0 +1,428 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Belevo\EnelixEMS;
|
||||||
|
|
||||||
|
use InvalidArgumentException;
|
||||||
|
use JsonException;
|
||||||
|
|
||||||
|
final class Anlagentopologie
|
||||||
|
{
|
||||||
|
public const VERSION = '1.0';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
* @throws JsonException
|
||||||
|
*/
|
||||||
|
public static function ausJson(
|
||||||
|
string $wechselrichterJson,
|
||||||
|
string $pvFlaechenJson,
|
||||||
|
string $batterienJson,
|
||||||
|
string $installationID
|
||||||
|
): array {
|
||||||
|
$wechselrichter = self::dekodiereListe($wechselrichterJson, 'AnlagenWechselrichter');
|
||||||
|
$pvFlaechen = self::dekodiereListe($pvFlaechenJson, 'AnlagenPVFlaechen');
|
||||||
|
$batterien = self::dekodiereListe($batterienJson, 'AnlagenBatterien');
|
||||||
|
|
||||||
|
$inverters = [];
|
||||||
|
$inverterById = [];
|
||||||
|
foreach ($wechselrichter as $index => $eintrag) {
|
||||||
|
$pfad = 'Wechselrichter ' . ($index + 1);
|
||||||
|
$id = self::kennung($eintrag['ID'] ?? null, $pfad . ': ID');
|
||||||
|
if (isset($inverterById[$id])) {
|
||||||
|
throw new InvalidArgumentException($pfad . ': ID ist mehrfach vorhanden.');
|
||||||
|
}
|
||||||
|
$typ = self::auswahl(
|
||||||
|
$eintrag['Typ'] ?? null,
|
||||||
|
['pv', 'battery', 'hybrid'],
|
||||||
|
$pfad . ': Typ'
|
||||||
|
);
|
||||||
|
$acRatedKw = self::positiveZahl(
|
||||||
|
$eintrag['ACNennleistung_kW'] ?? null,
|
||||||
|
$pfad . ': AC-Nennleistung'
|
||||||
|
);
|
||||||
|
$inverter = [
|
||||||
|
'id' => $id,
|
||||||
|
'name' => self::name($eintrag['Name'] ?? null, $id),
|
||||||
|
'type' => $typ,
|
||||||
|
'acRatedKw' => $acRatedKw,
|
||||||
|
'acExportLimitKw' => self::optionaleGrenze(
|
||||||
|
$eintrag['ACEinspeisegrenze_kW'] ?? null,
|
||||||
|
$acRatedKw,
|
||||||
|
$pfad . ': AC-Einspeisegrenze'
|
||||||
|
),
|
||||||
|
'acImportLimitKw' => self::optionaleGrenze(
|
||||||
|
$eintrag['ACBezugsgrenze_kW'] ?? null,
|
||||||
|
$acRatedKw,
|
||||||
|
$pfad . ': AC-Bezugsgrenze'
|
||||||
|
),
|
||||||
|
];
|
||||||
|
$inverters[] = $inverter;
|
||||||
|
$inverterById[$id] = $inverter;
|
||||||
|
}
|
||||||
|
|
||||||
|
$pvArrays = [];
|
||||||
|
$pvIds = [];
|
||||||
|
foreach ($pvFlaechen as $index => $eintrag) {
|
||||||
|
$pfad = 'PV-Flaeche ' . ($index + 1);
|
||||||
|
$id = self::eindeutigeKennung($eintrag['ID'] ?? null, $pvIds, $pfad);
|
||||||
|
$inverterId = self::kennung(
|
||||||
|
$eintrag['WechselrichterID'] ?? null,
|
||||||
|
$pfad . ': Wechselrichter-ID'
|
||||||
|
);
|
||||||
|
$inverter = self::wechselrichter($inverterById, $inverterId, $pfad);
|
||||||
|
if (!in_array($inverter['type'], ['pv', 'hybrid'], true)) {
|
||||||
|
throw new InvalidArgumentException(
|
||||||
|
$pfad . ': benoetigt einen PV- oder Hybridwechselrichter.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
$moduleCount = self::optionalePositiveGanzzahl(
|
||||||
|
$eintrag['Modulanzahl'] ?? null,
|
||||||
|
$pfad . ': Modulanzahl'
|
||||||
|
);
|
||||||
|
$modulePowerWp = self::optionalePositiveZahl(
|
||||||
|
$eintrag['Modulleistung_Wp'] ?? null,
|
||||||
|
$pfad . ': Modulleistung'
|
||||||
|
);
|
||||||
|
if (($moduleCount === null) !== ($modulePowerWp === null)) {
|
||||||
|
throw new InvalidArgumentException(
|
||||||
|
$pfad . ': Modulanzahl und Modulleistung muessen gemeinsam gepflegt werden.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
$pvArrays[] = [
|
||||||
|
'id' => $id,
|
||||||
|
'name' => self::name($eintrag['Name'] ?? null, $id),
|
||||||
|
'dcKwp' => self::positiveZahl(
|
||||||
|
$eintrag['DCLeistung_kWp'] ?? null,
|
||||||
|
$pfad . ': DC-Leistung'
|
||||||
|
),
|
||||||
|
'tiltDeg' => self::zahlImBereich(
|
||||||
|
$eintrag['Neigung_Grad'] ?? null,
|
||||||
|
0.0,
|
||||||
|
90.0,
|
||||||
|
$pfad . ': Neigung'
|
||||||
|
),
|
||||||
|
'azimuthDeg' => self::zahlImBereich(
|
||||||
|
$eintrag['Azimut_Grad'] ?? null,
|
||||||
|
-180.0,
|
||||||
|
180.0,
|
||||||
|
$pfad . ': Azimut'
|
||||||
|
),
|
||||||
|
'inverterId' => $inverterId,
|
||||||
|
'mpptInput' => self::optionalerText($eintrag['MPPT'] ?? null),
|
||||||
|
'moduleCount' => $moduleCount,
|
||||||
|
'modulePowerWp' => $modulePowerWp,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$batteryItems = [];
|
||||||
|
$batteryIds = [];
|
||||||
|
foreach ($batterien as $index => $eintrag) {
|
||||||
|
$pfad = 'Batterie ' . ($index + 1);
|
||||||
|
$id = self::eindeutigeKennung($eintrag['ID'] ?? null, $batteryIds, $pfad);
|
||||||
|
$inverterId = self::kennung(
|
||||||
|
$eintrag['WechselrichterID'] ?? null,
|
||||||
|
$pfad . ': Wechselrichter-ID'
|
||||||
|
);
|
||||||
|
$inverter = self::wechselrichter($inverterById, $inverterId, $pfad);
|
||||||
|
$kopplung = self::auswahl(
|
||||||
|
$eintrag['Kopplung'] ?? null,
|
||||||
|
['ac', 'dc', 'hybrid'],
|
||||||
|
$pfad . ': Kopplung'
|
||||||
|
);
|
||||||
|
if ($kopplung === 'ac' && !in_array($inverter['type'], ['battery', 'hybrid'], true)) {
|
||||||
|
throw new InvalidArgumentException(
|
||||||
|
$pfad . ': AC-Kopplung benoetigt einen Batterie- oder Hybridwechselrichter.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if ($kopplung !== 'ac' && $inverter['type'] !== 'hybrid') {
|
||||||
|
throw new InvalidArgumentException(
|
||||||
|
$pfad . ': DC- und Hybridkopplung benoetigen einen Hybridwechselrichter.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
$nominalCapacityKwh = self::positiveZahl(
|
||||||
|
$eintrag['Nennkapazitaet_kWh'] ?? null,
|
||||||
|
$pfad . ': Nennkapazitaet'
|
||||||
|
);
|
||||||
|
$usableCapacityKwh = self::positiveZahl(
|
||||||
|
$eintrag['Nutzkapazitaet_kWh'] ?? null,
|
||||||
|
$pfad . ': Nutzkapazitaet'
|
||||||
|
);
|
||||||
|
if ($usableCapacityKwh > $nominalCapacityKwh) {
|
||||||
|
throw new InvalidArgumentException(
|
||||||
|
$pfad . ': Nutzkapazitaet darf die Nennkapazitaet nicht uebersteigen.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
$batteryItems[] = [
|
||||||
|
'id' => $id,
|
||||||
|
'name' => self::name($eintrag['Name'] ?? null, $id),
|
||||||
|
'nominalCapacityKwh' => $nominalCapacityKwh,
|
||||||
|
'usableCapacityKwh' => $usableCapacityKwh,
|
||||||
|
'maxChargeKw' => self::positiveZahl(
|
||||||
|
$eintrag['MaxLadeleistung_kW'] ?? null,
|
||||||
|
$pfad . ': maximale Ladeleistung'
|
||||||
|
),
|
||||||
|
'maxDischargeKw' => self::positiveZahl(
|
||||||
|
$eintrag['MaxEntladeleistung_kW'] ?? null,
|
||||||
|
$pfad . ': maximale Entladeleistung'
|
||||||
|
),
|
||||||
|
'inverterId' => $inverterId,
|
||||||
|
'coupling' => $kopplung,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'version' => self::VERSION,
|
||||||
|
'installationId' => trim($installationID),
|
||||||
|
'inverters' => $inverters,
|
||||||
|
'pvArrays' => $pvArrays,
|
||||||
|
'batteries' => $batteryItems,
|
||||||
|
'sharedAcLimits' => self::gemeinsameAcGrenzen(
|
||||||
|
$inverters,
|
||||||
|
$pvArrays,
|
||||||
|
$batteryItems
|
||||||
|
),
|
||||||
|
'summary' => self::zusammenfassung($inverters, $pvArrays, $batteryItems),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return list<array<string, mixed>> */
|
||||||
|
private static function dekodiereListe(string $json, string $name): array
|
||||||
|
{
|
||||||
|
$daten = json_decode($json, true, 512, JSON_THROW_ON_ERROR);
|
||||||
|
if (!is_array($daten)
|
||||||
|
|| ($daten !== [] && array_keys($daten) !== range(0, count($daten) - 1))
|
||||||
|
) {
|
||||||
|
throw new InvalidArgumentException($name . ' muss eine JSON-Liste sein.');
|
||||||
|
}
|
||||||
|
foreach ($daten as $eintrag) {
|
||||||
|
if (!is_array($eintrag)) {
|
||||||
|
throw new InvalidArgumentException($name . ' enthaelt einen ungueltigen Eintrag.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $daten;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param array<string, bool> $verwendet */
|
||||||
|
private static function eindeutigeKennung($wert, array &$verwendet, string $pfad): string
|
||||||
|
{
|
||||||
|
$id = self::kennung($wert, $pfad . ': ID');
|
||||||
|
if (isset($verwendet[$id])) {
|
||||||
|
throw new InvalidArgumentException($pfad . ': ID ist mehrfach vorhanden.');
|
||||||
|
}
|
||||||
|
$verwendet[$id] = true;
|
||||||
|
|
||||||
|
return $id;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function kennung($wert, string $feld): string
|
||||||
|
{
|
||||||
|
if (!is_string($wert)
|
||||||
|
|| preg_match('/^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/', trim($wert)) !== 1
|
||||||
|
) {
|
||||||
|
throw new InvalidArgumentException(
|
||||||
|
$feld . ' muss 1 bis 64 Zeichen aus Buchstaben, Zahlen, Punkt, Minus oder Unterstrich enthalten.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return trim($wert);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function name($wert, string $fallback): string
|
||||||
|
{
|
||||||
|
if ($wert === null || $wert === '') {
|
||||||
|
return $fallback;
|
||||||
|
}
|
||||||
|
if (!is_string($wert) || strlen(trim($wert)) > 120) {
|
||||||
|
throw new InvalidArgumentException('Name muss ein Text mit maximal 120 Zeichen sein.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return trim($wert);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param list<string> $werte */
|
||||||
|
private static function auswahl($wert, array $werte, string $feld): string
|
||||||
|
{
|
||||||
|
if (!is_string($wert) || !in_array($wert, $werte, true)) {
|
||||||
|
throw new InvalidArgumentException($feld . ' ist ungueltig.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $wert;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function positiveZahl($wert, string $feld): float
|
||||||
|
{
|
||||||
|
if (!is_int($wert) && !is_float($wert)) {
|
||||||
|
throw new InvalidArgumentException($feld . ' muss eine Zahl sein.');
|
||||||
|
}
|
||||||
|
if (!is_finite((float) $wert) || (float) $wert <= 0.0) {
|
||||||
|
throw new InvalidArgumentException($feld . ' muss groesser als 0 sein.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return (float) $wert;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function optionalePositiveZahl($wert, string $feld): ?float
|
||||||
|
{
|
||||||
|
if ($wert === null || $wert === '' || $wert === 0 || $wert === 0.0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return self::positiveZahl($wert, $feld);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function optionalePositiveGanzzahl($wert, string $feld): ?int
|
||||||
|
{
|
||||||
|
if ($wert === null || $wert === '' || $wert === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!is_int($wert) || $wert <= 0) {
|
||||||
|
throw new InvalidArgumentException($feld . ' muss eine positive Ganzzahl sein.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $wert;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function zahlImBereich(
|
||||||
|
$wert,
|
||||||
|
float $minimum,
|
||||||
|
float $maximum,
|
||||||
|
string $feld
|
||||||
|
): float {
|
||||||
|
if ((!is_int($wert) && !is_float($wert))
|
||||||
|
|| !is_finite((float) $wert)
|
||||||
|
|| (float) $wert < $minimum
|
||||||
|
|| (float) $wert > $maximum
|
||||||
|
) {
|
||||||
|
throw new InvalidArgumentException(
|
||||||
|
sprintf('%s muss zwischen %g und %g liegen.', $feld, $minimum, $maximum)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (float) $wert;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function optionaleGrenze($wert, float $nennleistung, string $feld): ?float
|
||||||
|
{
|
||||||
|
$grenze = self::optionalePositiveZahl($wert, $feld);
|
||||||
|
if ($grenze !== null && $grenze > $nennleistung) {
|
||||||
|
throw new InvalidArgumentException(
|
||||||
|
$feld . ' darf die AC-Nennleistung nicht uebersteigen.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $grenze;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function optionalerText($wert): ?string
|
||||||
|
{
|
||||||
|
if ($wert === null || $wert === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!is_string($wert) || strlen(trim($wert)) > 64) {
|
||||||
|
throw new InvalidArgumentException('MPPT muss ein Text mit maximal 64 Zeichen sein.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return trim($wert);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, array<string, mixed>> $wechselrichter
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private static function wechselrichter(array $wechselrichter, string $id, string $pfad): array
|
||||||
|
{
|
||||||
|
if (!isset($wechselrichter[$id])) {
|
||||||
|
throw new InvalidArgumentException(
|
||||||
|
$pfad . ': referenzierter Wechselrichter ist nicht vorhanden.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $wechselrichter[$id];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<array<string, mixed>> $inverters
|
||||||
|
* @param list<array<string, mixed>> $pvArrays
|
||||||
|
* @param list<array<string, mixed>> $batteries
|
||||||
|
* @return list<array<string, mixed>>
|
||||||
|
*/
|
||||||
|
private static function gemeinsameAcGrenzen(
|
||||||
|
array $inverters,
|
||||||
|
array $pvArrays,
|
||||||
|
array $batteries
|
||||||
|
): array {
|
||||||
|
$result = [];
|
||||||
|
foreach ($inverters as $inverter) {
|
||||||
|
if ($inverter['type'] !== 'hybrid') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$pvIds = [];
|
||||||
|
foreach ($pvArrays as $pvArray) {
|
||||||
|
if ($pvArray['inverterId'] === $inverter['id']) {
|
||||||
|
$pvIds[] = $pvArray['id'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$batteryIds = [];
|
||||||
|
foreach ($batteries as $battery) {
|
||||||
|
if ($battery['inverterId'] === $inverter['id']) {
|
||||||
|
$batteryIds[] = $battery['id'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($pvIds === [] && $batteryIds === []) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$result[] = [
|
||||||
|
'inverterId' => $inverter['id'],
|
||||||
|
'acRatedKw' => $inverter['acRatedKw'],
|
||||||
|
'pvArrayIds' => $pvIds,
|
||||||
|
'batteryIds' => $batteryIds,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<array<string, mixed>> $inverters
|
||||||
|
* @param list<array<string, mixed>> $pvArrays
|
||||||
|
* @param list<array<string, mixed>> $batteries
|
||||||
|
* @return array<string, float>
|
||||||
|
*/
|
||||||
|
private static function zusammenfassung(
|
||||||
|
array $inverters,
|
||||||
|
array $pvArrays,
|
||||||
|
array $batteries
|
||||||
|
): array {
|
||||||
|
$pvInverterIds = [];
|
||||||
|
$pvDcKwp = 0.0;
|
||||||
|
foreach ($pvArrays as $pvArray) {
|
||||||
|
$pvDcKwp += $pvArray['dcKwp'];
|
||||||
|
$pvInverterIds[$pvArray['inverterId']] = true;
|
||||||
|
}
|
||||||
|
$pvAcKw = 0.0;
|
||||||
|
foreach ($inverters as $inverter) {
|
||||||
|
if (isset($pvInverterIds[$inverter['id']])) {
|
||||||
|
$pvAcKw += $inverter['acRatedKw'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$usableBatteryKwh = 0.0;
|
||||||
|
$maxBatteryChargeKw = 0.0;
|
||||||
|
$maxBatteryDischargeKw = 0.0;
|
||||||
|
foreach ($batteries as $battery) {
|
||||||
|
$usableBatteryKwh += $battery['usableCapacityKwh'];
|
||||||
|
$maxBatteryChargeKw += $battery['maxChargeKw'];
|
||||||
|
$maxBatteryDischargeKw += $battery['maxDischargeKw'];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'pvDcKwp' => $pvDcKwp,
|
||||||
|
'pvAcKw' => $pvAcKw,
|
||||||
|
'usableBatteryKwh' => $usableBatteryKwh,
|
||||||
|
'maxBatteryChargeKw' => $maxBatteryChargeKw,
|
||||||
|
'maxBatteryDischargeKw' => $maxBatteryDischargeKw,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Belevo\EnelixEMS\Tests;
|
||||||
|
|
||||||
|
use Belevo\EnelixEMS\Anlagentopologie;
|
||||||
|
use InvalidArgumentException;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
final class AnlagentopologieTest extends TestCase
|
||||||
|
{
|
||||||
|
public function testHybridwechselrichterVerknuepftPvUndBatterieMitGemeinsamerAcGrenze(): void
|
||||||
|
{
|
||||||
|
$topologie = Anlagentopologie::ausJson(
|
||||||
|
json_encode([[
|
||||||
|
'ID' => 'hybrid-1',
|
||||||
|
'Name' => 'Hybrid Ost',
|
||||||
|
'Typ' => 'hybrid',
|
||||||
|
'ACNennleistung_kW' => 10.0,
|
||||||
|
'ACEinspeisegrenze_kW' => 8.0,
|
||||||
|
'ACBezugsgrenze_kW' => 0.0,
|
||||||
|
]], JSON_THROW_ON_ERROR),
|
||||||
|
json_encode([
|
||||||
|
[
|
||||||
|
'ID' => 'pv-ost',
|
||||||
|
'Name' => 'Ostdach',
|
||||||
|
'DCLeistung_kWp' => 6.4,
|
||||||
|
'Neigung_Grad' => 25.0,
|
||||||
|
'Azimut_Grad' => -90.0,
|
||||||
|
'WechselrichterID' => 'hybrid-1',
|
||||||
|
'MPPT' => 'A',
|
||||||
|
'Modulanzahl' => 16,
|
||||||
|
'Modulleistung_Wp' => 400.0,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'ID' => 'pv-west',
|
||||||
|
'Name' => 'Westdach',
|
||||||
|
'DCLeistung_kWp' => 6.4,
|
||||||
|
'Neigung_Grad' => 25.0,
|
||||||
|
'Azimut_Grad' => 90.0,
|
||||||
|
'WechselrichterID' => 'hybrid-1',
|
||||||
|
'MPPT' => 'B',
|
||||||
|
'Modulanzahl' => 16,
|
||||||
|
'Modulleistung_Wp' => 400.0,
|
||||||
|
],
|
||||||
|
], JSON_THROW_ON_ERROR),
|
||||||
|
json_encode([[
|
||||||
|
'ID' => 'bat-1',
|
||||||
|
'Name' => 'Hausspeicher',
|
||||||
|
'Nennkapazitaet_kWh' => 13.5,
|
||||||
|
'Nutzkapazitaet_kWh' => 12.8,
|
||||||
|
'MaxLadeleistung_kW' => 5.0,
|
||||||
|
'MaxEntladeleistung_kW' => 5.0,
|
||||||
|
'WechselrichterID' => 'hybrid-1',
|
||||||
|
'Kopplung' => 'hybrid',
|
||||||
|
]], JSON_THROW_ON_ERROR),
|
||||||
|
'2a518707-8d62-4d97-82ce-606c95726500'
|
||||||
|
);
|
||||||
|
|
||||||
|
self::assertSame(Anlagentopologie::VERSION, $topologie['version']);
|
||||||
|
self::assertSame(12.8, $topologie['summary']['pvDcKwp']);
|
||||||
|
self::assertSame(10.0, $topologie['summary']['pvAcKw']);
|
||||||
|
self::assertSame(12.8, $topologie['summary']['usableBatteryKwh']);
|
||||||
|
self::assertSame([[
|
||||||
|
'inverterId' => 'hybrid-1',
|
||||||
|
'acRatedKw' => 10.0,
|
||||||
|
'pvArrayIds' => ['pv-ost', 'pv-west'],
|
||||||
|
'batteryIds' => ['bat-1'],
|
||||||
|
]], $topologie['sharedAcLimits']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testLeereTopologieBleibtFuerBestandsinstallationenGueltig(): void
|
||||||
|
{
|
||||||
|
$topologie = Anlagentopologie::ausJson(
|
||||||
|
'[]',
|
||||||
|
'[]',
|
||||||
|
'[]',
|
||||||
|
'2a518707-8d62-4d97-82ce-606c95726500'
|
||||||
|
);
|
||||||
|
|
||||||
|
self::assertSame([], $topologie['inverters']);
|
||||||
|
self::assertSame([], $topologie['pvArrays']);
|
||||||
|
self::assertSame([], $topologie['batteries']);
|
||||||
|
self::assertSame(0.0, $topologie['summary']['pvDcKwp']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testPvFlaecheBenoetigtEinenVorhandenenPassendenWechselrichter(): void
|
||||||
|
{
|
||||||
|
$this->expectException(InvalidArgumentException::class);
|
||||||
|
$this->expectExceptionMessage('referenzierter Wechselrichter ist nicht vorhanden');
|
||||||
|
|
||||||
|
Anlagentopologie::ausJson(
|
||||||
|
'[]',
|
||||||
|
json_encode([[
|
||||||
|
'ID' => 'pv-1',
|
||||||
|
'DCLeistung_kWp' => 5.0,
|
||||||
|
'Neigung_Grad' => 30.0,
|
||||||
|
'Azimut_Grad' => 0.0,
|
||||||
|
'WechselrichterID' => 'wr-fehlt',
|
||||||
|
]], JSON_THROW_ON_ERROR),
|
||||||
|
'[]',
|
||||||
|
'2a518707-8d62-4d97-82ce-606c95726500'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testDcBatterieBenoetigtEinenHybridwechselrichter(): void
|
||||||
|
{
|
||||||
|
$this->expectException(InvalidArgumentException::class);
|
||||||
|
$this->expectExceptionMessage('DC- und Hybridkopplung');
|
||||||
|
|
||||||
|
Anlagentopologie::ausJson(
|
||||||
|
json_encode([[
|
||||||
|
'ID' => 'bat-wr',
|
||||||
|
'Typ' => 'battery',
|
||||||
|
'ACNennleistung_kW' => 5.0,
|
||||||
|
]], JSON_THROW_ON_ERROR),
|
||||||
|
'[]',
|
||||||
|
json_encode([[
|
||||||
|
'ID' => 'bat-1',
|
||||||
|
'Nennkapazitaet_kWh' => 10.0,
|
||||||
|
'Nutzkapazitaet_kWh' => 9.0,
|
||||||
|
'MaxLadeleistung_kW' => 4.0,
|
||||||
|
'MaxEntladeleistung_kW' => 4.0,
|
||||||
|
'WechselrichterID' => 'bat-wr',
|
||||||
|
'Kopplung' => 'dc',
|
||||||
|
]], JSON_THROW_ON_ERROR),
|
||||||
|
'2a518707-8d62-4d97-82ce-606c95726500'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testModuldatenMuessenGemeinsamGesetztWerden(): void
|
||||||
|
{
|
||||||
|
$this->expectException(InvalidArgumentException::class);
|
||||||
|
$this->expectExceptionMessage('Modulanzahl und Modulleistung');
|
||||||
|
|
||||||
|
Anlagentopologie::ausJson(
|
||||||
|
json_encode([[
|
||||||
|
'ID' => 'pv-wr',
|
||||||
|
'Typ' => 'pv',
|
||||||
|
'ACNennleistung_kW' => 5.0,
|
||||||
|
]], JSON_THROW_ON_ERROR),
|
||||||
|
json_encode([[
|
||||||
|
'ID' => 'pv-1',
|
||||||
|
'DCLeistung_kWp' => 6.0,
|
||||||
|
'Neigung_Grad' => 30.0,
|
||||||
|
'Azimut_Grad' => 0.0,
|
||||||
|
'WechselrichterID' => 'pv-wr',
|
||||||
|
'Modulanzahl' => 15,
|
||||||
|
'Modulleistung_Wp' => 0.0,
|
||||||
|
]], JSON_THROW_ON_ERROR),
|
||||||
|
'[]',
|
||||||
|
'2a518707-8d62-4d97-82ce-606c95726500'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -56,6 +56,9 @@ final class ManagerModulstrukturTest extends TestCase
|
|||||||
'LizenzInstallationID',
|
'LizenzInstallationID',
|
||||||
'LizenzInformation',
|
'LizenzInformation',
|
||||||
'VerbraucherAktualisieren',
|
'VerbraucherAktualisieren',
|
||||||
|
'AnlagenWechselrichter',
|
||||||
|
'AnlagenPVFlaechen',
|
||||||
|
'AnlagenBatterien',
|
||||||
] as $property) {
|
] as $property) {
|
||||||
self::assertStringContainsString($property, $json);
|
self::assertStringContainsString($property, $json);
|
||||||
}
|
}
|
||||||
@@ -171,6 +174,40 @@ final class ManagerModulstrukturTest extends TestCase
|
|||||||
self::assertStringNotContainsString("'publicKey' =>", $inhalt);
|
self::assertStringNotContainsString("'publicKey' =>", $inhalt);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function testManagerSynchronisiertDieTopologieMitSeparatemGeraetezugang(): void
|
||||||
|
{
|
||||||
|
$inhalt = (string) file_get_contents(__DIR__ . '/../Manager/module.php');
|
||||||
|
|
||||||
|
self::assertStringContainsString(
|
||||||
|
'https://license.enelix.ch/api/v1/installations/%s/prognosis/topology',
|
||||||
|
$inhalt
|
||||||
|
);
|
||||||
|
self::assertStringContainsString(
|
||||||
|
"RegisterAttributeString('PrognoseInstallationsToken', '')",
|
||||||
|
$inhalt
|
||||||
|
);
|
||||||
|
self::assertStringContainsString("'issueDeviceToken' =>", $inhalt);
|
||||||
|
self::assertStringContainsString("unset(\$neueLease['deviceToken'])", $inhalt);
|
||||||
|
self::assertStringContainsString('synchronisiereAnlagentopologie()', $inhalt);
|
||||||
|
self::assertStringContainsString("'Authorization: Bearer ' . \$token", $inhalt);
|
||||||
|
self::assertStringNotContainsString(
|
||||||
|
"protokolliere('Prognosesynchronisation', \$token)",
|
||||||
|
$inhalt
|
||||||
|
);
|
||||||
|
|
||||||
|
$leasePruefung = strpos($inhalt, 'Lizenzpruefung::pruefeLease(');
|
||||||
|
$tokenSpeicherung = strpos(
|
||||||
|
$inhalt,
|
||||||
|
"WriteAttributeString('PrognoseInstallationsToken', \$geraeteToken)"
|
||||||
|
);
|
||||||
|
self::assertNotFalse($leasePruefung);
|
||||||
|
self::assertNotFalse($tokenSpeicherung);
|
||||||
|
self::assertTrue(
|
||||||
|
$leasePruefung < $tokenSpeicherung,
|
||||||
|
'Der Gerätezugang darf erst nach validierter Lizenzantwort gespeichert werden.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
public function testManagerVerwendetDieNeuenZeitdefaults(): void
|
public function testManagerVerwendetDieNeuenZeitdefaults(): void
|
||||||
{
|
{
|
||||||
$inhalt = (string) file_get_contents(__DIR__ . '/../Manager/module.php');
|
$inhalt = (string) file_get_contents(__DIR__ . '/../Manager/module.php');
|
||||||
@@ -193,6 +230,8 @@ final class ManagerModulstrukturTest extends TestCase
|
|||||||
{
|
{
|
||||||
$inhalt = (string) file_get_contents(__DIR__ . '/../Manager/module.php');
|
$inhalt = (string) file_get_contents(__DIR__ . '/../Manager/module.php');
|
||||||
self::assertStringContainsString('implements ManagerSchnittstelle', $inhalt);
|
self::assertStringContainsString('implements ManagerSchnittstelle', $inhalt);
|
||||||
|
self::assertStringContainsString("require_once __DIR__ . '/../libs/Anlagentopologie.php'", $inhalt);
|
||||||
|
self::assertStringContainsString('public function AnlagentopologieExportieren(): string', $inhalt);
|
||||||
self::assertStringContainsString('VerbraucherdatenEmpfangen(array $daten)', $inhalt);
|
self::assertStringContainsString('VerbraucherdatenEmpfangen(array $daten)', $inhalt);
|
||||||
self::assertStringContainsString("'ManagerdatenEmpfangen'", $inhalt);
|
self::assertStringContainsString("'ManagerdatenEmpfangen'", $inhalt);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -72,6 +72,35 @@ return static function (TestContext $test): void {
|
|||||||
['InstanzID' => $warmwasserId, 'Aktiv' => true],
|
['InstanzID' => $warmwasserId, 'Aktiv' => true],
|
||||||
['InstanzID' => $pufferspeicherId, 'Aktiv' => true],
|
['InstanzID' => $pufferspeicherId, 'Aktiv' => true],
|
||||||
];
|
];
|
||||||
|
$wechselrichterKonfiguration = [[
|
||||||
|
'ID' => 'hybrid-1',
|
||||||
|
'Name' => 'Hybridwechselrichter',
|
||||||
|
'Typ' => 'hybrid',
|
||||||
|
'ACNennleistung_kW' => 10.0,
|
||||||
|
'ACEinspeisegrenze_kW' => 8.0,
|
||||||
|
'ACBezugsgrenze_kW' => 0.0,
|
||||||
|
]];
|
||||||
|
$pvKonfiguration = [[
|
||||||
|
'ID' => 'pv-1',
|
||||||
|
'Name' => 'Suedflaeche',
|
||||||
|
'DCLeistung_kWp' => 12.0,
|
||||||
|
'Neigung_Grad' => 30.0,
|
||||||
|
'Azimut_Grad' => 0.0,
|
||||||
|
'WechselrichterID' => 'hybrid-1',
|
||||||
|
'MPPT' => 'A',
|
||||||
|
'Modulanzahl' => 30,
|
||||||
|
'Modulleistung_Wp' => 400.0,
|
||||||
|
]];
|
||||||
|
$batterieKonfiguration = [[
|
||||||
|
'ID' => 'bat-1',
|
||||||
|
'Name' => 'Hausspeicher',
|
||||||
|
'Nennkapazitaet_kWh' => 13.5,
|
||||||
|
'Nutzkapazitaet_kWh' => 12.8,
|
||||||
|
'MaxLadeleistung_kW' => 5.0,
|
||||||
|
'MaxEntladeleistung_kW' => 5.0,
|
||||||
|
'WechselrichterID' => 'hybrid-1',
|
||||||
|
'Kopplung' => 'hybrid',
|
||||||
|
]];
|
||||||
$lizenzcode = trim((string) getenv('ENELIX_TEST_LICENSE_CODE'));
|
$lizenzcode = trim((string) getenv('ENELIX_TEST_LICENSE_CODE'));
|
||||||
$managerId = $test->createInstance(
|
$managerId = $test->createInstance(
|
||||||
'{6F771B18-59D4-4C8A-B951-3B2FE9F6A2C4}',
|
'{6F771B18-59D4-4C8A-B951-3B2FE9F6A2C4}',
|
||||||
@@ -87,6 +116,12 @@ return static function (TestContext $test): void {
|
|||||||
'Lastspitzengrenze' => 0.0,
|
'Lastspitzengrenze' => 0.0,
|
||||||
'SollwertSolarladen' => 0.0,
|
'SollwertSolarladen' => 0.0,
|
||||||
'Umschaltdifferenz' => 0.0,
|
'Umschaltdifferenz' => 0.0,
|
||||||
|
'AnlagenWechselrichter' => json_encode(
|
||||||
|
$wechselrichterKonfiguration,
|
||||||
|
JSON_THROW_ON_ERROR
|
||||||
|
),
|
||||||
|
'AnlagenPVFlaechen' => json_encode($pvKonfiguration, JSON_THROW_ON_ERROR),
|
||||||
|
'AnlagenBatterien' => json_encode($batterieKonfiguration, JSON_THROW_ON_ERROR),
|
||||||
'DiagnosevariablenAnzeigen' => true,
|
'DiagnosevariablenAnzeigen' => true,
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
@@ -163,6 +198,21 @@ return static function (TestContext $test): void {
|
|||||||
$vorher = $lizenzInstallationID();
|
$vorher = $lizenzInstallationID();
|
||||||
IPS_ApplyChanges($managerId);
|
IPS_ApplyChanges($managerId);
|
||||||
$test->assertSame($vorher, $lizenzInstallationID());
|
$test->assertSame($vorher, $lizenzInstallationID());
|
||||||
|
|
||||||
|
$topologie = json_decode(
|
||||||
|
ENELIX_AnlagentopologieExportieren($managerId),
|
||||||
|
true,
|
||||||
|
512,
|
||||||
|
JSON_THROW_ON_ERROR
|
||||||
|
);
|
||||||
|
$test->assertSame($vorher, $topologie['installationId'] ?? null);
|
||||||
|
$test->assertSame('1.0', $topologie['version'] ?? null);
|
||||||
|
$test->assertSame(12.0, $topologie['summary']['pvDcKwp'] ?? null);
|
||||||
|
$test->assertSame(10.0, $topologie['summary']['pvAcKw'] ?? null);
|
||||||
|
$test->assertSame(12.8, $topologie['summary']['usableBatteryKwh'] ?? null);
|
||||||
|
$test->assertSame('hybrid-1', $topologie['sharedAcLimits'][0]['inverterId'] ?? null);
|
||||||
|
$test->assertSame(['pv-1'], $topologie['sharedAcLimits'][0]['pvArrayIds'] ?? null);
|
||||||
|
$test->assertSame(['bat-1'], $topologie['sharedAcLimits'][0]['batteryIds'] ?? null);
|
||||||
});
|
});
|
||||||
$zustandswert = static function (array $daten, int $instanzId, string $kennung) {
|
$zustandswert = static function (array $daten, int $instanzId, string $kennung) {
|
||||||
foreach ($daten['Verbraucher'] ?? [] as $verbraucher) {
|
foreach ($daten['Verbraucher'] ?? [] as $verbraucher) {
|
||||||
|
|||||||
Reference in New Issue
Block a user