feat(utils): adapt virtual battery and VGT interface
Tests / test (push) Successful in 46s

This commit is contained in:
dh
2026-09-27 12:28:32 +00:00
parent 92eec804a7
commit f11b1fbe61
22 changed files with 2445 additions and 104 deletions
+9
View File
@@ -0,0 +1,9 @@
# VGT-Schnittstelle
Installierbares IP-Symcon-Modul fuer Enelix Utils ab Version 8.0.
Das Modul fuehrt die bisherigen PV- und Batterie-SDL-Module zusammen. Die
bestehenden MQTT-Topics und Payload-Felder bleiben unveraendert.
Die vollstaendige Konfiguration steht in
[`docs/module/VGT-Schnittstelle/README.md`](../docs/module/VGT-Schnittstelle/README.md).
+78
View File
@@ -0,0 +1,78 @@
{
"elements": [
{
"type": "Label",
"caption": "Die MQTT-Topics und Nutzdaten entsprechen unveraendert den bisherigen MQTTPVSDL- und MQTTBatterySDL-Modulen."
},
{
"type": "Select",
"name": "Geraeteart",
"caption": "Geraeteart",
"options": [
{"caption": "PV", "value": 0},
{"caption": "Batterie / SDL-Anteil", "value": 1}
]
},
{
"type": "ValidationTextBox",
"name": "TopicSuffix",
"caption": "Topic Suffix"
},
{
"type": "SelectVariable",
"name": "ReqActionID",
"caption": "Ausgabe-Variable / SDL-Sollleistung"
},
{
"type": "SelectVariable",
"name": "PowerProductionID",
"caption": "Aktuelle Leistung SDL"
},
{
"type": "SelectVariable",
"name": "SoCID",
"caption": "SDL-Ladezustand (nur Batterie)"
},
{
"type": "NumberSpinner",
"name": "TargetSoC",
"caption": "Ziel-Ladezustand",
"suffix": " %",
"minimum": 0,
"maximum": 100
},
{
"type": "NumberSpinner",
"name": "ChargePower",
"caption": "Ladeleistung",
"suffix": " W",
"minimum": 0
},
{
"type": "NumberSpinner",
"name": "DischargePower",
"caption": "Entladeleistung",
"suffix": " W",
"minimum": 0
},
{
"type": "NumberSpinner",
"name": "MaxPowerSetpoint",
"caption": "Maximaler Power Setpoint",
"suffix": " W",
"minimum": 0
},
{
"type": "CheckBox",
"name": "LoggingEin",
"caption": "Debug-Logging aktivieren"
}
],
"actions": [
{
"type": "Button",
"caption": "Steuerung testen",
"onClick": "ENLVGT_RunControl($id);"
}
]
}
+126
View File
@@ -0,0 +1,126 @@
<?php
declare(strict_types=1);
namespace Belevo\EnelixUtils;
use InvalidArgumentException;
final class VGTRegler
{
public const GERAETEART_PV = 0;
public const GERAETEART_BATTERIE = 1;
public static function begrenzeSetpoint(
int $geraeteart,
float $wert,
float $maximum
): float {
self::pruefeGeraeteart($geraeteart);
if ($maximum < 0.0) {
throw new InvalidArgumentException(
'MaxPowerSetpoint darf nicht negativ sein.'
);
}
if ($geraeteart === self::GERAETEART_PV) {
return min($maximum, max(0.0, $wert));
}
return min($maximum, max(-$maximum, $wert));
}
public static function zielwert(
int $geraeteart,
string $strategie,
float $setpoint,
float $maximum,
float $ladezustand,
float $zielLadezustand,
float $ladeleistung,
float $entladeleistung,
float $minLadezustand,
float $maxLadezustand
): float {
self::pruefeGeraeteart($geraeteart);
$strategie = trim($strategie);
$setpoint = self::begrenzeSetpoint(
$geraeteart,
$setpoint,
$maximum
);
if ($strategie === 'stop') {
return $geraeteart === self::GERAETEART_PV ? $maximum : 0.0;
}
if ($strategie === 'activate') {
$ziel = $geraeteart === self::GERAETEART_PV
? $setpoint
: -$setpoint;
return self::begrenzeNachLadezustand(
$geraeteart,
$ziel,
$ladezustand,
$minLadezustand,
$maxLadezustand
);
}
if ($geraeteart === self::GERAETEART_PV) {
return $maximum;
}
if ($ladezustand >= $zielLadezustand
&& $ladezustand <= $zielLadezustand + 1.0
) {
return 0.0;
}
$ziel = $ladezustand < $zielLadezustand
? abs($ladeleistung)
: -abs($entladeleistung);
$ziel = self::begrenzeSetpoint($geraeteart, $ziel, $maximum);
return self::begrenzeNachLadezustand(
$geraeteart,
$ziel,
$ladezustand,
$minLadezustand,
$maxLadezustand
);
}
private static function begrenzeNachLadezustand(
int $geraeteart,
float $zielW,
float $ladezustand,
float $minimum,
float $maximum
): float {
if ($geraeteart === self::GERAETEART_PV) {
return $zielW;
}
if ($minimum < 0.0
|| $maximum > 100.0
|| $minimum > $maximum
) {
throw new InvalidArgumentException(
'MinSoC und MaxSoC sind ungueltig.'
);
}
if (($zielW < 0.0 && $ladezustand <= $minimum)
|| ($zielW > 0.0 && $ladezustand >= $maximum)
) {
return 0.0;
}
return $zielW;
}
private static function pruefeGeraeteart(int $geraeteart): void
{
if (!in_array($geraeteart, [
self::GERAETEART_PV,
self::GERAETEART_BATTERIE,
], true)) {
throw new InvalidArgumentException('Geraeteart ist ungueltig.');
}
}
}
+20
View File
@@ -0,0 +1,20 @@
{
"id": "{4BA1A557-7970-4FB9-93B8-1F6ADA0A5A94}",
"name": "VGTSchnittstelle",
"type": 3,
"vendor": "Enelix",
"aliases": [
"VGT-Schnittstelle",
"MQTT PV SDL",
"MQTT Battery SDL"
],
"parentRequirements": [
"{043EA491-0325-4ADD-8FC2-A30C8EEB4D3F}"
],
"childRequirements": [],
"implemented": [
"{7F7632D9-FA40-4F38-8DEA-C83CD4325A32}"
],
"prefix": "ENLVGT",
"url": ""
}
+432
View File
@@ -0,0 +1,432 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/libs/VGTRegler.php';
use Belevo\EnelixUtils\VGTRegler;
class VGTSchnittstelle extends IPSModule
{
private const STATUS_AKTIV = 102;
private const STATUS_KONFIGURATION_UNGUELTIG = 201;
private const STATUS_AUSGABE_FEHLER = 202;
public function Create(): void
{
parent::Create();
$this->RegisterPropertyInteger(
'Geraeteart',
VGTRegler::GERAETEART_PV
);
$this->RegisterPropertyString('TopicSuffix', '');
$this->RegisterPropertyInteger('ReqActionID', 0);
$this->RegisterPropertyInteger('PowerProductionID', 0);
$this->RegisterPropertyInteger('SoCID', 0);
$this->RegisterPropertyFloat('TargetSoC', 50.0);
$this->RegisterPropertyInteger('ChargePower', 2500);
$this->RegisterPropertyInteger('DischargePower', 2500);
$this->RegisterPropertyInteger('MaxPowerSetpoint', 10000);
$this->RegisterPropertyBoolean('LoggingEin', false);
$this->RegisterVariableBoolean('IsReady', 'Is Ready', '', 10);
$this->RegisterVariableBoolean('IsRunning', 'Is Running', '', 20);
$this->RegisterVariableFloat('MinSoC', 'Min SoC', '', 30);
$this->EnableAction('MinSoC');
$this->RegisterVariableFloat('MaxSoC', 'Max SoC', '', 40);
$this->EnableAction('MaxSoC');
$this->RegisterVariableInteger(
'PowerSetpoint',
'Power Setpoint',
'',
50
);
$this->EnableAction('PowerSetpoint');
$this->RegisterVariableString('Strategy', 'Strategy', '', 60);
$this->EnableAction('Strategy');
$this->RegisterVariableString(
'LastReadResponse',
'Letzte Lese-Antwort',
'',
70
);
$this->RegisterVariableString(
'LastWriteResponse',
'Letzte Steuer-Antwort',
'',
80
);
$this->RegisterVariableBoolean('Stoerung', 'Stoerung', '~Switch', 90);
$this->RegisterVariableString('Stoertext', 'Stoertext', '', 100);
$this->RegisterAttributeString('PublishQueue', '[]');
$this->RegisterAttributeFloat('LetzterZielwert', 0.0);
$this->RegisterAttributeBoolean('SoCGrenzenInitialisiert', false);
$this->ConnectParent('{F7A0DD2E-7684-95C0-64C2-D2A9DC47577B}');
$this->RegisterTimer(
'PublishDelay',
0,
'ENLVGT_DoDelayedPublish($_IPS["TARGET"]);'
);
$this->RegisterTimer(
'BatteryControlTimer',
0,
'ENLVGT_RunControl($_IPS["TARGET"]);'
);
}
public function ApplyChanges(): void
{
parent::ApplyChanges();
try {
if (!$this->ReadAttributeBoolean('SoCGrenzenInitialisiert')) {
$this->SetValue('MinSoC', 0.0);
$this->SetValue('MaxSoC', 100.0);
$this->WriteAttributeBoolean('SoCGrenzenInitialisiert', true);
}
$this->pruefeKonfiguration();
$suffix = $this->ReadPropertyString('TopicSuffix');
$filter = '.*"Topic":"(feedback-request|remote-control-request)\\/'
. preg_quote($suffix, '/')
. '".*';
$this->SetReceiveDataFilter($filter);
$this->SetTimerInterval('BatteryControlTimer', 3000);
$this->SetValue('IsReady', true);
$this->setzeFehler('');
$this->SetStatus(self::STATUS_AKTIV);
$this->RunControl();
} catch (Throwable $fehler) {
$this->SetTimerInterval('BatteryControlTimer', 0);
$this->SetValue('IsReady', false);
$this->SetValue('IsRunning', false);
$this->setzeFehler($fehler->getMessage());
$this->SetStatus(self::STATUS_KONFIGURATION_UNGUELTIG);
}
}
public function RequestAction($ident, $wert): void
{
switch ($ident) {
case 'PowerSetpoint':
$begrenzt = VGTRegler::begrenzeSetpoint(
$this->ReadPropertyInteger('Geraeteart'),
(float) $wert,
(float) $this->ReadPropertyInteger('MaxPowerSetpoint')
);
$this->SetValue('PowerSetpoint', (int) round($begrenzt));
$this->RunControl();
return;
case 'Strategy':
$this->SetValue('Strategy', trim((string) $wert));
$this->RunControl();
return;
case 'MinSoC':
$this->SetValue(
'MinSoC',
min(100.0, max(0.0, (float) $wert))
);
$this->RunControl();
return;
case 'MaxSoC':
$this->SetValue(
'MaxSoC',
min(100.0, max(0.0, (float) $wert))
);
$this->RunControl();
return;
}
throw new InvalidArgumentException('Unbekannte Aktion: ' . $ident);
}
public function RunControl(): void
{
try {
$this->pruefeKonfiguration();
$geraeteart = $this->ReadPropertyInteger('Geraeteart');
$strategie = (string) $this->GetValue('Strategy');
if ($geraeteart === VGTRegler::GERAETEART_PV
&& !in_array($strategie, ['activate', 'stop'], true)
) {
$this->SetValue('IsReady', true);
$this->SetValue('IsRunning', false);
$this->setzeFehler('');
$this->SetStatus(self::STATUS_AKTIV);
return;
}
$ladezustand = $geraeteart === VGTRegler::GERAETEART_BATTERIE
? $this->leseNumerisch($this->ReadPropertyInteger('SoCID'))
: 0.0;
$zielwert = VGTRegler::zielwert(
$geraeteart,
$strategie,
(float) $this->GetValue('PowerSetpoint'),
(float) $this->ReadPropertyInteger('MaxPowerSetpoint'),
$ladezustand,
$this->ReadPropertyFloat('TargetSoC'),
(float) $this->ReadPropertyInteger('ChargePower'),
(float) $this->ReadPropertyInteger('DischargePower'),
(float) $this->GetValue('MinSoC'),
(float) $this->GetValue('MaxSoC')
);
RequestAction(
$this->ReadPropertyInteger('ReqActionID'),
$zielwert
);
$this->WriteAttributeFloat('LetzterZielwert', $zielwert);
$this->SetValue('IsReady', true);
$this->SetValue('IsRunning', abs($zielwert) >= 0.5);
$this->setzeFehler('');
$this->SetStatus(self::STATUS_AKTIV);
$this->protokolliere('Zielwert', $zielwert);
} catch (Throwable $fehler) {
$this->SetValue('IsRunning', false);
$this->setzeFehler($fehler->getMessage());
$this->SetStatus(self::STATUS_AUSGABE_FEHLER);
$this->protokolliere('Steuerfehler', $fehler->getMessage());
}
}
public function BuildReadResponse(): string
{
$data = [
'power_production' => $this->leseOptionalNumerisch(
$this->ReadPropertyInteger('PowerProductionID')
),
'is_ready' => (bool) $this->GetValue('IsReady'),
'is_running' => (bool) $this->GetValue('IsRunning'),
];
if ($this->ReadPropertyInteger('Geraeteart')
=== VGTRegler::GERAETEART_BATTERIE
) {
$data['state_of_charge'] = $this->leseOptionalNumerisch(
$this->ReadPropertyInteger('SoCID')
);
$data['min_soc'] = (float) $this->GetValue('MinSoC');
$data['max_soc'] = (float) $this->GetValue('MaxSoC');
}
$json = json_encode($data, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR);
$this->SetValue('LastReadResponse', $json);
return $json;
}
public function HandleRemoteControlJSON(string $payload): ?string
{
if ($payload === '') {
return null;
}
$data = json_decode($payload, true);
if (!is_array($data)) {
return null;
}
if (array_key_exists('power_setpoint', $data)) {
$wert = VGTRegler::begrenzeSetpoint(
$this->ReadPropertyInteger('Geraeteart'),
(float) $data['power_setpoint'],
(float) $this->ReadPropertyInteger('MaxPowerSetpoint')
);
$this->SetValue('PowerSetpoint', (int) round($wert));
}
if (array_key_exists('strategy', $data)) {
$this->SetValue('Strategy', (string) $data['strategy']);
}
$this->RunControl();
$antwort = [
'power_setpoint' => (int) $this->GetValue('PowerSetpoint'),
'strategy' => (string) $this->GetValue('Strategy'),
];
$json = json_encode(
$antwort,
JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR
);
$this->SetValue('LastWriteResponse', $json);
return $json;
}
public function ReceiveData($jsonString): void
{
$data = json_decode((string) $jsonString, true);
if (!is_array($data)
|| (int) ($data['PacketType'] ?? 0) !== 3
|| !isset($data['Topic'])
) {
return;
}
$topic = (string) $data['Topic'];
$payload = (string) ($data['Payload'] ?? '');
$suffix = $this->ReadPropertyString('TopicSuffix');
$this->protokolliere('ReceiveData', $jsonString);
if ($topic === 'feedback-request/' . $suffix) {
$this->QueuePublish(
'feedback-response/' . $suffix,
$this->BuildReadResponse()
);
return;
}
if ($topic === 'remote-control-request/' . $suffix) {
$antwort = $this->HandleRemoteControlJSON($payload);
if ($antwort !== null) {
$this->QueuePublish(
'remote-control-response/' . $suffix,
$antwort
);
}
}
}
public function QueuePublish(string $topic, string $payload): void
{
$queue = json_decode(
$this->ReadAttributeString('PublishQueue'),
true
);
if (!is_array($queue)) {
$queue = [];
}
$queue[] = ['Topic' => $topic, 'Payload' => $payload];
if (count($queue) > 100) {
array_shift($queue);
}
$this->WriteAttributeString(
'PublishQueue',
json_encode($queue, JSON_THROW_ON_ERROR)
);
$this->SetTimerInterval('PublishDelay', 50);
}
public function DoDelayedPublish(): void
{
$queue = json_decode(
$this->ReadAttributeString('PublishQueue'),
true
);
if (!is_array($queue) || $queue === []) {
$this->SetTimerInterval('PublishDelay', 0);
return;
}
$eintrag = array_shift($queue);
$this->WriteAttributeString(
'PublishQueue',
json_encode($queue, JSON_THROW_ON_ERROR)
);
$this->SendDataToParent(json_encode([
'DataID' => '{043EA491-0325-4ADD-8FC2-A30C8EEB4D3F}',
'PacketType' => 3,
'QualityOfService' => 0,
'Retain' => false,
'Topic' => (string) $eintrag['Topic'],
'Payload' => (string) $eintrag['Payload'],
], JSON_THROW_ON_ERROR));
$this->SetTimerInterval('PublishDelay', $queue === [] ? 0 : 50);
}
private function pruefeKonfiguration(): void
{
VGTRegler::begrenzeSetpoint(
$this->ReadPropertyInteger('Geraeteart'),
0.0,
(float) $this->ReadPropertyInteger('MaxPowerSetpoint')
);
if (trim($this->ReadPropertyString('TopicSuffix')) === '') {
throw new InvalidArgumentException(
'TopicSuffix ist nicht eingerichtet.'
);
}
$this->pruefeAktionsvariable(
$this->ReadPropertyInteger('ReqActionID'),
'ReqActionID'
);
if ($this->ReadPropertyInteger('Geraeteart')
=== VGTRegler::GERAETEART_BATTERIE
) {
$this->pruefeNumerischeVariable(
$this->ReadPropertyInteger('SoCID'),
'SoCID'
);
}
}
private function pruefeAktionsvariable(int $id, string $name): void
{
$this->pruefeNumerischeVariable($id, $name);
$variable = IPS_GetVariable($id);
if ((int) ($variable['VariableAction'] ?? 0) <= 0
&& (int) ($variable['VariableCustomAction'] ?? 0) <= 0
) {
throw new InvalidArgumentException(
$name . ' benoetigt eine Variablenaktion.'
);
}
}
private function pruefeNumerischeVariable(int $id, string $name): void
{
if ($id <= 0 || !IPS_VariableExists($id)) {
throw new InvalidArgumentException($name . ' ist nicht eingerichtet.');
}
$variable = IPS_GetVariable($id);
if (!in_array((int) $variable['VariableType'], [1, 2], true)) {
throw new InvalidArgumentException(
$name . ' muss numerisch sein.'
);
}
}
private function leseNumerisch(int $id): float
{
$this->pruefeNumerischeVariable($id, 'Messvariable');
return (float) GetValue($id);
}
private function leseOptionalNumerisch(int $id): float
{
if ($id <= 0 || !IPS_VariableExists($id)) {
return 0.0;
}
$variable = IPS_GetVariable($id);
if (!in_array((int) $variable['VariableType'], [1, 2], true)) {
return 0.0;
}
return (float) GetValue($id);
}
private function setzeFehler(string $text): void
{
$this->SetValue('Stoerung', $text !== '');
$this->SetValue('Stoertext', $text);
}
/** @param mixed $daten */
private function protokolliere(string $titel, $daten): void
{
if (!$this->ReadPropertyBoolean('LoggingEin')) {
return;
}
$text = is_string($daten)
? $daten
: json_encode($daten, JSON_THROW_ON_ERROR);
$this->SendDebug($titel, $text, 0);
}
}