2 Commits
Author SHA1 Message Date
dh f11b1fbe61 feat(utils): adapt virtual battery and VGT interface
Tests / test (push) Successful in 46s
2026-09-27 12:28:32 +00:00
dh 92eec804a7 feat(utils): adapt WAGO CC100 hardware module 2026-09-27 08:50:16 +00:00
32 changed files with 3848 additions and 126 deletions
+136
View File
@@ -0,0 +1,136 @@
# CC100 Hardware
> Status: Implementiert fuer den WAGO Compact Controller 100, Artikel 751-9301,
> mit IP-Symcon ab Version 8.0.
Das Modul bindet die komplette Onboard-I/O des CC100 direkt ueber die von WAGO
dokumentierten Linux-Schnittstellen an:
- 8 digitale Eingaenge (DI)
- 4 digitale Ausgaenge (DO)
- 2 analoge Eingaenge 0 bis 10 V (AI)
- 2 analoge Ausgaenge 0 bis 10 V (AO)
- 2 PT1000-Temperatureingaenge (PT)
## Kanalauswahl
Die Property 'Kanalauswahl' enthaelt alle 18 Kanaele. Pro Zeile werden
Aktivierung, Typ, Kanalnummer, Anzeigename und Abfrageintervall festgelegt.
Nur aktive Kanaele werden als IP-Symcon-Variablen angelegt. Wird ein Kanal
deaktiviert, entfernt das Modul seine dynamische Variable beim naechsten
Uebernehmen der Konfiguration.
DO- und AO-Variablen sind bedienbar. DI, AI und PT sind reine Messwerte.
AI und AO werden in Volt dargestellt, PT in Grad Celsius.
## Properties
| Property | Standard | Bedeutung |
| --- | --- | --- |
| 'Kanalauswahl' | alle Kanaele inaktiv | Sichtbare und abgefragte Kanaele |
| 'DigitalInputPath' | WAGO-DI-Sammelpfad | 8-Bit-Eingangswert, LSB ist DI1 |
| 'DigitalOutputPath' | '/sys/kernel/dout_drv/DOUT_DATA' | 4-Bit-Ausgangswert, LSB ist DO1 |
| 'CalibrationPath' | '/etc/calib' | WAGO-Zweipunktkalibrierung |
| 'AI1Path', 'AI2Path' | WAGO-IIO-Pfade | Analoge Eingangswerte |
| 'AO1ValuePath', 'AO2ValuePath' | WAGO-IIO-Pfade | Analoge Ausgangswerte |
| 'AO1PowerPath', 'AO2PowerPath' | WAGO-IIO-Pfade | Freigabe der analogen Ausgaenge |
| 'PT1Path', 'PT2Path' | WAGO-IIO-Pfade | PT1000-Rohwerte |
| 'Debug' | 'false' | Zusaetzliche Diagnose im Debug-Log |
Die Hardwarepfade sind im Konfigurationsformular unter **Erweiterte
Hardwarepfade** sichtbar. Dadurch koennen Firmwarevarianten angepasst werden,
ohne den Modulcode zu aendern.
## Aktualisierung
Die CC100-Treiber stellen die Onboard-I/O als lesbare beziehungsweise
schreibbare Systemdateien bereit. Fuer diese Dateien ist von WAGO kein
zuverlaessiger Aenderungs-Eventkanal dokumentiert. Das Modul verwendet deshalb
einen gemeinsamen Timer:
1. Das kleinste aktive Kanalintervall bestimmt den Timer.
2. Pro Lauf werden nur faellige Kanaele verarbeitet.
3. DI beziehungsweise DO werden je Lauf nur einmal als Sammelwert gelesen.
4. IP-Symcon-Werte werden nur bei einer echten Wertaenderung geschrieben.
Das Intervall kann pro Kanal zwischen 100 ms und 3 600 000 ms eingestellt
werden. Kurze Intervalle erhoehen CPU- und I/O-Last.
## Kalibrierung
AI, AO und PT verwenden die individuellen Zweipunktwerte aus '/etc/calib'.
Das Modul akzeptiert die WAGO-Bezeichnungen 'AO1'/'AO2' sowie die in der
WAGO-Beispieldatei vorkommenden Schreibweisen 'A01'/'A02'.
- AI-Rohwerte werden kalibriert und von mV nach V umgerechnet.
- AO-Sollwerte werden von V nach mV und danach invers in den Rohwert umgerechnet.
- PT-Rohwerte werden zuerst in Ohm und anschliessend mit der
Callendar-Van-Dusen-Kennlinie in Grad Celsius umgerechnet.
## Ausgangssicherheit
'ApplyChanges()' schreibt keine Ausgaenge. Beim Start werden DO und AO nur
eingelesen. Ein DO-Schaltbefehl liest zuerst den gesamten Ausgangswert, aendert
genau das gewaehlte Bit und erhaelt die anderen Ausgaenge. AO akzeptiert nur
Werte von 0 bis 10 V und schreibt zuerst die WAGO-Freigabe, danach den
kalibrierten Rohwert.
Die rekursive Schreibschleife des Enelix-1-Moduls wurde verworfen. Ein
fehlgeschlagener Schreibzugriff blockiert IP-Symcon nicht bis zu fuenf Sekunden,
sondern liefert sofort Status '203' und einen Stoertext.
## Variablen
| Ident | Typ | Zugriff |
| --- | --- | --- |
| 'DI1' bis 'DI8' | Boolean | Anzeige, nur wenn aktiviert |
| 'DO1' bis 'DO4' | Boolean | Bedienbar, nur wenn aktiviert |
| 'AI1', 'AI2' | Float / V | Anzeige, nur wenn aktiviert |
| 'AO1', 'AO2' | Float / V | Bedienbar, nur wenn aktiviert |
| 'PT1', 'PT2' | Float / Grad Celsius | Anzeige, nur wenn aktiviert |
| 'Hardwarestatus' | Integer | '0' unkonfiguriert, '1' bereit, '2' Lesefehler, '3' Fehler |
| 'Stoertext' | String | Letzter zusammengefasster Fehler |
## Migration aus Enelix 1
### Unveraendert uebernommen
- vier DO, acht DI und zwei PT-Kanaele
- Bitreihenfolge mit DI1 beziehungsweise DO1 als LSB
- die vorhandenen WAGO-Systempfade als Standardwerte
### Angepasst
- getrennte feste Timer durch einen gruppierten, kanalspezifischen Poller ersetzt
- DO-Aktionen lesen den Sammelwert und aendern nur das gewaehlte Bit
- PT-Auswertung verwendet die individuelle Kalibrierung und Kennlinie
### Neu implementiert
- beide AI- und AO-Kanaele
- frei waehlbare Kanaele und dynamische Variablen
- konfigurierbare Intervalle und ueberschreibbare Hardwarepfade
- Status-, Fehler- und Debugdiagnose
### Verworfen
- rekursives Schreiben mit bis zu fuenf Sekunden Blockierzeit
- zyklisches Neuschreiben aller DO ohne neuen Schaltbefehl
- feste PT-Rohwertbereiche und nicht kalibrierte Skalierungsfaktoren
Das alte Modul kann nicht in-place aktualisiert werden. Fuer die Umstellung:
1. Vorhandene Ausgangszustaende und verwendete Kanaele dokumentieren.
2. Eine neue Instanz 'CC100 Hardware' anlegen.
3. Benoetigte Kanaele aktivieren und Anzeigenamen vergeben.
4. Hardwarepfade und Kalibrierdatei pruefen.
5. Zuerst nur Eingaenge testen.
6. Ausgaenge einzeln und unter Aufsicht freigeben.
## RS-485
RS-485 gehoert nicht zur Onboard-I/O-Abbildung dieses Moduls. Der CC100 stellt
die Schnittstelle als '/dev/ttySTM1' bereit. Protokoll, Teilnehmeradressen,
Baudrate, Paritaet, Registerbereiche und Zeitvorgaben gehoeren in eine separate
serielle beziehungsweise Modbus-Instanz. So bleibt die I/O-Abfrage unabhaengig
von langsamen oder fehlerhaften Busteilnehmern.
+153
View File
@@ -0,0 +1,153 @@
{
"elements": [
{
"type": "Label",
"caption": "WAGO CC100 751-9301: Nur aktivierte Kanaele werden als Variablen angelegt."
},
{
"type": "List",
"name": "Kanalauswahl",
"caption": "Onboard-I/O",
"add": true,
"delete": true,
"sortable": false,
"columns": [
{
"caption": "Aktiv",
"name": "Aktiv",
"width": "10%",
"add": false,
"edit": {
"type": "CheckBox"
}
},
{
"caption": "Typ",
"name": "Typ",
"width": "12%",
"add": "DI",
"edit": {
"type": "Select",
"options": [
{ "caption": "Digitaler Eingang", "value": "DI" },
{ "caption": "Digitaler Ausgang", "value": "DO" },
{ "caption": "Analoger Eingang", "value": "AI" },
{ "caption": "Analoger Ausgang", "value": "AO" },
{ "caption": "PT1000", "value": "PT" }
]
}
},
{
"caption": "Kanal",
"name": "Kanal",
"width": "10%",
"add": 1,
"edit": {
"type": "NumberSpinner",
"minimum": 1,
"maximum": 8
}
},
{
"caption": "Anzeigename",
"name": "Anzeigename",
"width": "38%",
"add": "",
"edit": {
"type": "ValidationTextBox"
}
},
{
"caption": "Polling (ms)",
"name": "Abfrageintervall",
"width": "20%",
"add": 1000,
"edit": {
"type": "NumberSpinner",
"minimum": 100,
"maximum": 3600000
}
}
]
},
{
"type": "ExpansionPanel",
"caption": "Erweiterte Hardwarepfade",
"expanded": false,
"items": [
{
"type": "ValidationTextBox",
"name": "DigitalInputPath",
"caption": "Digitale Eingaenge"
},
{
"type": "ValidationTextBox",
"name": "DigitalOutputPath",
"caption": "Digitale Ausgaenge"
},
{
"type": "ValidationTextBox",
"name": "CalibrationPath",
"caption": "Kalibrierdatei"
},
{
"type": "ValidationTextBox",
"name": "AI1Path",
"caption": "AI1 Rohwert"
},
{
"type": "ValidationTextBox",
"name": "AI2Path",
"caption": "AI2 Rohwert"
},
{
"type": "ValidationTextBox",
"name": "AO1ValuePath",
"caption": "AO1 Rohwert"
},
{
"type": "ValidationTextBox",
"name": "AO1PowerPath",
"caption": "AO1 Freigabe"
},
{
"type": "ValidationTextBox",
"name": "AO2ValuePath",
"caption": "AO2 Rohwert"
},
{
"type": "ValidationTextBox",
"name": "AO2PowerPath",
"caption": "AO2 Freigabe"
},
{
"type": "ValidationTextBox",
"name": "PT1Path",
"caption": "PT1 Rohwert"
},
{
"type": "ValidationTextBox",
"name": "PT2Path",
"caption": "PT2 Rohwert"
}
]
},
{
"type": "CheckBox",
"name": "Debug",
"caption": "Debug-Logging aktivieren"
}
],
"actions": [
{
"type": "Button",
"caption": "Kanaele jetzt einlesen",
"onClick": "IPS_RequestAction($id, 'Poll', true);"
}
],
"status": [
{ "code": 201, "icon": "error", "caption": "Konfiguration ungueltig" },
{ "code": 202, "icon": "error", "caption": "Hardware nicht lesbar" },
{ "code": 203, "icon": "error", "caption": "Hardware konnte nicht geschrieben werden" }
]
}
+277
View File
@@ -0,0 +1,277 @@
<?php
declare(strict_types=1);
namespace Belevo\EnelixUtils\CC100;
use InvalidArgumentException;
use RuntimeException;
final class CC100ChannelModel
{
/** @var array<string, int> */
private const CHANNEL_COUNTS = [
'DI' => 8,
'DO' => 4,
'AI' => 2,
'AO' => 2,
'PT' => 2,
];
/** @var array<string, int> */
private const DEFAULT_INTERVALS = [
'DI' => 500,
'DO' => 500,
'AI' => 5000,
'AO' => 5000,
'PT' => 5000,
];
/**
* @return list<array{Aktiv: bool, Typ: string, Kanal: int, Anzeigename: string, Abfrageintervall: int}>
*/
public static function defaultSelection(): array
{
$selection = [];
foreach (self::CHANNEL_COUNTS as $type => $count) {
for ($channel = 1; $channel <= $count; $channel++) {
$selection[] = [
'Aktiv' => false,
'Typ' => $type,
'Kanal' => $channel,
'Anzeigename' => self::defaultName($type, $channel),
'Abfrageintervall' => self::DEFAULT_INTERVALS[$type],
];
}
}
return $selection;
}
/**
* @return list<array{
* key: string,
* type: string,
* channel: int,
* name: string,
* active: bool,
* pollInterval: int
* }>
*/
public static function normalizeSelection(string $json): array
{
try {
$rows = json_decode($json, true, 512, JSON_THROW_ON_ERROR);
} catch (\JsonException $exception) {
throw new InvalidArgumentException('Kanalauswahl ist kein gueltiges JSON.', 0, $exception);
}
if (!is_array($rows)) {
throw new InvalidArgumentException('Kanalauswahl muss eine Liste sein.');
}
$result = [];
$keys = [];
foreach ($rows as $index => $row) {
if (!is_array($row)) {
throw new InvalidArgumentException('Kanalzeile ' . ($index + 1) . ' ist ungueltig.');
}
$type = strtoupper(trim((string) ($row['Typ'] ?? '')));
$channel = filter_var($row['Kanal'] ?? null, FILTER_VALIDATE_INT);
if ($channel === false || !isset(self::CHANNEL_COUNTS[$type])) {
throw new InvalidArgumentException('Kanalzeile ' . ($index + 1) . ' hat einen ungueltigen Typ oder Kanal.');
}
if ($channel < 1 || $channel > self::CHANNEL_COUNTS[$type]) {
throw new InvalidArgumentException(
sprintf('%s erlaubt nur die Kanaele 1 bis %d.', $type, self::CHANNEL_COUNTS[$type])
);
}
$key = $type . $channel;
if (isset($keys[$key])) {
throw new InvalidArgumentException('Kanal ' . $key . ' ist doppelt konfiguriert.');
}
$keys[$key] = true;
$name = trim((string) ($row['Anzeigename'] ?? ''));
if ($name === '') {
$name = self::defaultName($type, $channel);
}
if (strlen($name) > 80) {
throw new InvalidArgumentException('Der Anzeigename von ' . $key . ' ist zu lang.');
}
$pollInterval = filter_var($row['Abfrageintervall'] ?? null, FILTER_VALIDATE_INT);
if ($pollInterval === false || $pollInterval < 100 || $pollInterval > 3600000) {
throw new InvalidArgumentException(
'Das Abfrageintervall von ' . $key . ' muss zwischen 100 und 3600000 ms liegen.'
);
}
$result[] = [
'key' => $key,
'type' => $type,
'channel' => $channel,
'name' => $name,
'active' => (bool) ($row['Aktiv'] ?? false),
'pollInterval' => $pollInterval,
];
}
return $result;
}
/**
* @return array{variableType: int, profile: string, writable: bool, position: int}
*/
public static function definition(string $type, int $channel): array
{
$type = strtoupper($type);
if (!isset(self::CHANNEL_COUNTS[$type]) || $channel < 1 || $channel > self::CHANNEL_COUNTS[$type]) {
throw new InvalidArgumentException('Unbekannter CC100-Kanal.');
}
$offsets = ['DI' => 100, 'DO' => 200, 'AI' => 300, 'AO' => 400, 'PT' => 500];
return [
'variableType' => in_array($type, ['DI', 'DO'], true) ? 0 : 2,
'profile' => match ($type) {
'DO' => '~Switch',
'AI', 'AO' => '~Volt',
'PT' => '~Temperature',
default => '',
},
'writable' => in_array($type, ['DO', 'AO'], true),
'position' => $offsets[$type] + $channel,
];
}
public static function defaultName(string $type, int $channel): string
{
return match ($type) {
'DI' => 'Digitaler Eingang ' . $channel,
'DO' => 'Digitaler Ausgang ' . $channel,
'AI' => 'Analoger Eingang ' . $channel,
'AO' => 'Analoger Ausgang ' . $channel,
'PT' => 'PT1000 Temperatur ' . $channel,
default => $type . $channel,
};
}
/**
* @return array<string, array{x1: float, y1: float, x2: float, y2: float}>
*/
public static function parseCalibration(string $content): array
{
$lines = preg_split('/\R/', trim($content)) ?: [];
$lines = array_values(array_filter(array_map('trim', $lines), static fn (string $line): bool => $line !== ''));
if (count($lines) < 2) {
throw new InvalidArgumentException('Kalibrierdatei ist leer oder unvollstaendig.');
}
$headers = preg_split('/\s+/', array_shift($lines)) ?: [];
$calibration = [];
foreach ($headers as $index => $header) {
if (!isset($lines[$index])) {
throw new InvalidArgumentException('Kalibrierwert fuer ' . $header . ' fehlt.');
}
$key = strtoupper($header);
if ($key === 'A01') {
$key = 'AO1';
} elseif ($key === 'A02') {
$key = 'AO2';
}
$values = preg_split('/\s+/', $lines[$index]) ?: [];
if (count($values) !== 4 || count(array_filter($values, 'is_numeric')) !== 4) {
throw new InvalidArgumentException('Kalibrierwert fuer ' . $key . ' ist ungueltig.');
}
[$x1, $y1, $x2, $y2] = array_map('floatval', $values);
if ($x1 === $x2 || $y1 === $y2) {
throw new InvalidArgumentException('Kalibrierpunkte fuer ' . $key . ' sind nicht eindeutig.');
}
$calibration[$key] = compact('x1', 'y1', 'x2', 'y2');
}
return $calibration;
}
/** @param array{x1: float, y1: float, x2: float, y2: float} $calibration */
public static function rawToEngineering(float $raw, array $calibration): float
{
return (($calibration['y2'] - $calibration['y1']) / ($calibration['x2'] - $calibration['x1']))
* ($raw - $calibration['x1'])
+ $calibration['y1'];
}
/** @param array{x1: float, y1: float, x2: float, y2: float} $calibration */
public static function engineeringToRaw(float $value, array $calibration): int
{
$raw = (($calibration['x2'] - $calibration['x1']) / ($calibration['y2'] - $calibration['y1']))
* ($value - $calibration['y1'])
+ $calibration['x1'];
return (int) round($raw);
}
public static function pt1000Temperature(float $resistance): float
{
if (!is_finite($resistance) || $resistance < 760.0 || $resistance > 2300.0) {
throw new InvalidArgumentException('PT1000-Widerstand liegt ausserhalb des unterstuetzten Bereichs.');
}
$low = -60.0;
$high = 350.0;
for ($iteration = 0; $iteration < 50; $iteration++) {
$middle = ($low + $high) / 2.0;
if (self::pt1000Resistance($middle) < $resistance) {
$low = $middle;
} else {
$high = $middle;
}
}
return round(($low + $high) / 2.0, 1);
}
public static function parseInteger(string $content): int
{
$value = trim($content);
if (!preg_match('/^-?\d+$/', $value)) {
throw new RuntimeException('Hardwarewert ist keine ganze Zahl.');
}
return (int) $value;
}
public static function bitState(int $aggregate, int $channel): bool
{
if ($channel < 1 || $channel > 8) {
throw new InvalidArgumentException('Bitkanal muss zwischen 1 und 8 liegen.');
}
return ($aggregate & (1 << ($channel - 1))) !== 0;
}
public static function withBitState(int $aggregate, int $channel, bool $state): int
{
$mask = 1 << ($channel - 1);
return $state ? ($aggregate | $mask) : ($aggregate & ~$mask);
}
private static function pt1000Resistance(float $temperature): float
{
$a = 3.9083e-3;
$b = -5.775e-7;
$c = -4.183e-12;
$factor = 1.0 + ($a * $temperature) + ($b * $temperature * $temperature);
if ($temperature < 0.0) {
$factor += $c * ($temperature - 100.0) * $temperature * $temperature * $temperature;
}
return 1000.0 * $factor;
}
}
+14
View File
@@ -0,0 +1,14 @@
{
"id": "{D62EA550-68A5-48E2-BF31-1C721AA1BD4E}",
"name": "CC100 Hardware",
"type": 3,
"vendor": "Belevo AG",
"aliases": [
"WAGO CC100 Hardware"
],
"parentRequirements": [],
"childRequirements": [],
"implemented": [],
"prefix": "CC100",
"url": ""
}
+405
View File
@@ -0,0 +1,405 @@
<?php
declare(strict_types=1);
use Belevo\EnelixUtils\CC100\CC100ChannelModel;
require_once __DIR__ . '/libs/CC100ChannelModel.php';
class CC100Hardware extends IPSModule
{
private const STATUS_CONFIGURATION_INVALID = 201;
private const STATUS_HARDWARE_UNAVAILABLE = 202;
private const STATUS_WRITE_FAILED = 203;
public function Create()
{
parent::Create();
$this->RegisterPropertyString(
'Kanalauswahl',
json_encode(CC100ChannelModel::defaultSelection(), JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR)
);
$this->RegisterPropertyString(
'DigitalInputPath',
'/sys/devices/platform/soc/44009000.spi/spi_master/spi0/spi0.0/din'
);
$this->RegisterPropertyString('DigitalOutputPath', '/sys/kernel/dout_drv/DOUT_DATA');
$this->RegisterPropertyString('CalibrationPath', '/etc/calib');
$this->RegisterPropertyString('AI1Path', '/sys/bus/iio/devices/iio:device3/in_voltage3_raw');
$this->RegisterPropertyString('AI2Path', '/sys/bus/iio/devices/iio:device3/in_voltage0_raw');
$this->RegisterPropertyString('AO1ValuePath', '/sys/bus/iio/devices/iio:device0/out_voltage1_raw');
$this->RegisterPropertyString('AO1PowerPath', '/sys/bus/iio/devices/iio:device0/out_voltage1_powerdown');
$this->RegisterPropertyString('AO2ValuePath', '/sys/bus/iio/devices/iio:device1/out_voltage2_raw');
$this->RegisterPropertyString('AO2PowerPath', '/sys/bus/iio/devices/iio:device1/out_voltage2_powerdown');
$this->RegisterPropertyString('PT1Path', '/sys/bus/iio/devices/iio:device2/in_voltage13_raw');
$this->RegisterPropertyString('PT2Path', '/sys/bus/iio/devices/iio:device2/in_voltage1_raw');
$this->RegisterPropertyBoolean('Debug', false);
$this->RegisterAttributeString('LastPoll', '{}');
$this->RegisterAttributeString('Calibration', '{}');
$this->RegisterTimer(
'PollChannels',
0,
'IPS_RequestAction(' . $this->InstanceID . ', "Poll", true);'
);
$this->RegisterVariableInteger('Hardwarestatus', 'Hardwarestatus', '', 900);
$this->RegisterVariableString('Stoertext', 'Stoertext', '', 901);
}
public function ApplyChanges()
{
parent::ApplyChanges();
try {
$channels = CC100ChannelModel::normalizeSelection($this->ReadPropertyString('Kanalauswahl'));
$this->synchronizeVariables($channels);
$activeChannels = array_values(array_filter(
$channels,
static fn (array $channel): bool => $channel['active']
));
$this->SetSummary(count($activeChannels) . ' Kanaele aktiv');
$this->WriteAttributeString('LastPoll', '{}');
$this->WriteAttributeString('Calibration', '{}');
if ($activeChannels === []) {
$this->SetTimerInterval('PollChannels', 0);
$this->setHardwareStatus(0, 'Keine Kanaele aktiviert.');
$this->SetStatus(IS_ACTIVE);
return;
}
$intervals = array_column($activeChannels, 'pollInterval');
$this->SetTimerInterval('PollChannels', min($intervals));
$this->PollChannels(true);
} catch (Throwable $exception) {
$this->SetTimerInterval('PollChannels', 0);
$this->setHardwareStatus(3, $exception->getMessage());
$this->SetStatus(self::STATUS_CONFIGURATION_INVALID);
$this->debug('Konfiguration', $exception->getMessage());
}
}
public function RequestAction($Ident, $Value)
{
if ($Ident === 'Poll') {
$this->PollChannels((bool) $Value);
return;
}
if (preg_match('/^(DO|AO)([1-9][0-9]*)$/', (string) $Ident, $matches) !== 1) {
throw new InvalidArgumentException('Unbekannte Aktion ' . $Ident . '.');
}
$type = $matches[1];
$channel = (int) $matches[2];
if ($type === 'DO') {
$this->SetDigitalOutput($channel, (bool) $Value);
return;
}
if (!is_int($Value) && !is_float($Value)) {
throw new InvalidArgumentException('Analoger Ausgang erwartet einen Zahlenwert in Volt.');
}
$this->SetAnalogOutput($channel, (float) $Value);
}
public function PollChannels(bool $force = false): void
{
try {
$channels = array_values(array_filter(
CC100ChannelModel::normalizeSelection($this->ReadPropertyString('Kanalauswahl')),
static fn (array $channel): bool => $channel['active']
));
} catch (Throwable $exception) {
$this->setHardwareStatus(3, $exception->getMessage());
$this->SetStatus(self::STATUS_CONFIGURATION_INVALID);
return;
}
if ($channels === []) {
return;
}
$now = (int) round(microtime(true) * 1000);
$lastPoll = json_decode($this->ReadAttributeString('LastPoll'), true);
if (!is_array($lastPoll)) {
$lastPoll = [];
}
$due = array_values(array_filter(
$channels,
static fn (array $channel): bool => $force
|| $now - (int) ($lastPoll[$channel['key']] ?? 0) >= $channel['pollInterval']
));
if ($due === []) {
return;
}
$errors = [];
$fileCache = [];
foreach ($due as $channel) {
try {
$value = $this->readChannel($channel, $fileCache);
$this->setValueIfChanged($channel['key'], $value);
} catch (Throwable $exception) {
$errors[] = $channel['key'] . ': ' . $exception->getMessage();
$this->debug('Lesefehler ' . $channel['key'], $exception->getMessage());
}
$lastPoll[$channel['key']] = $now;
}
$this->WriteAttributeString(
'LastPoll',
json_encode($lastPoll, JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR)
);
if ($errors === []) {
$this->setHardwareStatus(1, '');
$this->SetStatus(IS_ACTIVE);
return;
}
$this->setHardwareStatus(2, implode(' | ', $errors));
$this->SetStatus(self::STATUS_HARDWARE_UNAVAILABLE);
}
public function SetDigitalOutput(int $channel, bool $state): void
{
$key = 'DO' . $channel;
$this->assertWritableSelected($key);
try {
$path = $this->ReadPropertyString('DigitalOutputPath');
$aggregate = CC100ChannelModel::parseInteger($this->readFile($path));
$newValue = CC100ChannelModel::withBitState($aggregate, $channel, $state);
if ($newValue !== $aggregate) {
$this->writeFile($path, (string) $newValue);
}
$this->setValueIfChanged($key, $state);
$this->PollChannels(true);
} catch (Throwable $exception) {
$this->setHardwareStatus(3, $key . ': ' . $exception->getMessage());
$this->SetStatus(self::STATUS_WRITE_FAILED);
throw $exception;
}
}
public function SetAnalogOutput(int $channel, float $voltage): void
{
$key = 'AO' . $channel;
$this->assertWritableSelected($key);
if (!is_finite($voltage) || $voltage < 0.0 || $voltage > 10.0) {
throw new InvalidArgumentException('Analoger Ausgang muss zwischen 0 und 10 V liegen.');
}
try {
$calibration = $this->calibrationFor($key);
$raw = CC100ChannelModel::engineeringToRaw($voltage * 1000.0, $calibration);
$this->writeFile($this->ReadPropertyString($key . 'PowerPath'), '0');
$this->writeFile($this->ReadPropertyString($key . 'ValuePath'), (string) $raw);
$this->setValueIfChanged($key, round($voltage, 3));
$this->PollChannels(true);
} catch (Throwable $exception) {
$this->setHardwareStatus(3, $key . ': ' . $exception->getMessage());
$this->SetStatus(self::STATUS_WRITE_FAILED);
throw $exception;
}
}
/**
* @param list<array{
* key: string,
* type: string,
* channel: int,
* name: string,
* active: bool,
* pollInterval: int
* }> $channels
*/
private function synchronizeVariables(array $channels): void
{
$configured = [];
foreach ($channels as $channel) {
$configured[$channel['key']] = $channel;
if (!$channel['active']) {
continue;
}
$definition = CC100ChannelModel::definition($channel['type'], $channel['channel']);
if ($definition['variableType'] === 0) {
$this->RegisterVariableBoolean(
$channel['key'],
$channel['name'],
$definition['profile'],
$definition['position']
);
} else {
$this->RegisterVariableFloat(
$channel['key'],
$channel['name'],
$definition['profile'],
$definition['position']
);
}
if ($definition['writable']) {
$this->EnableAction($channel['key']);
}
}
foreach (CC100ChannelModel::defaultSelection() as $default) {
$key = $default['Typ'] . $default['Kanal'];
if (($configured[$key]['active'] ?? false) === true) {
continue;
}
if (@$this->GetIDForIdent($key) !== false) {
$this->UnregisterVariable($key);
}
}
}
/**
* @param array{key: string, type: string, channel: int} $channel
* @param array<string, int> $fileCache
* @return bool|float
*/
private function readChannel(array $channel, array &$fileCache)
{
$type = $channel['type'];
$number = $channel['channel'];
if ($type === 'DI' || $type === 'DO') {
$property = $type === 'DI' ? 'DigitalInputPath' : 'DigitalOutputPath';
$path = $this->ReadPropertyString($property);
if (!array_key_exists($path, $fileCache)) {
$fileCache[$path] = CC100ChannelModel::parseInteger($this->readFile($path));
}
return CC100ChannelModel::bitState($fileCache[$path], $number);
}
$key = $type . $number;
$raw = CC100ChannelModel::parseInteger($this->readFile($this->pathFor($key)));
$engineering = CC100ChannelModel::rawToEngineering($raw, $this->calibrationFor($key));
if ($type === 'PT') {
return CC100ChannelModel::pt1000Temperature($engineering);
}
return round($engineering / 1000.0, 3);
}
private function pathFor(string $key): string
{
if (str_starts_with($key, 'AI') || str_starts_with($key, 'PT')) {
return $this->ReadPropertyString($key . 'Path');
}
if (str_starts_with($key, 'AO')) {
return $this->ReadPropertyString($key . 'ValuePath');
}
throw new InvalidArgumentException('Kein Einzelpfad fuer ' . $key . '.');
}
/** @return array{x1: float, y1: float, x2: float, y2: float} */
private function calibrationFor(string $key): array
{
$stored = json_decode($this->ReadAttributeString('Calibration'), true);
if (!is_array($stored) || $stored === []) {
$stored = CC100ChannelModel::parseCalibration(
$this->readFile($this->ReadPropertyString('CalibrationPath'))
);
$this->WriteAttributeString(
'Calibration',
json_encode($stored, JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR)
);
}
if (!isset($stored[$key]) || !is_array($stored[$key])) {
throw new RuntimeException('Kalibrierung fuer ' . $key . ' fehlt.');
}
return $stored[$key];
}
private function assertWritableSelected(string $key): void
{
$channels = CC100ChannelModel::normalizeSelection($this->ReadPropertyString('Kanalauswahl'));
foreach ($channels as $channel) {
if ($channel['key'] !== $key || !$channel['active']) {
continue;
}
$definition = CC100ChannelModel::definition($channel['type'], $channel['channel']);
if ($definition['writable']) {
return;
}
}
throw new RuntimeException($key . ' ist nicht als bedienbarer Kanal aktiviert.');
}
private function readFile(string $path): string
{
if ($path === '' || !is_readable($path)) {
throw new RuntimeException('Datei ist nicht lesbar: ' . $path);
}
$content = @file_get_contents($path);
if ($content === false) {
throw new RuntimeException('Datei konnte nicht gelesen werden: ' . $path);
}
return $content;
}
private function writeFile(string $path, string $value): void
{
if ($path === '') {
throw new RuntimeException('Ausgabepfad ist leer.');
}
$written = @file_put_contents($path, $value);
if ($written === false) {
throw new RuntimeException('Datei konnte nicht geschrieben werden: ' . $path);
}
$this->debug('Hardware schreiben', $path . ' <- ' . $value);
}
/** @param bool|float $value */
private function setValueIfChanged(string $ident, $value): void
{
$variableID = @$this->GetIDForIdent($ident);
if ($variableID === false) {
return;
}
$current = $this->GetValue($ident);
if (is_float($value)) {
if (is_float($current) && abs($current - $value) < 0.0001) {
return;
}
} elseif ($current === $value) {
return;
}
$this->SetValue($ident, $value);
}
private function setHardwareStatus(int $status, string $message): void
{
$this->setValueIfChanged('Hardwarestatus', $status);
$this->setValueIfChanged('Stoertext', $message);
}
/** @param mixed $message */
private function debug(string $title, $message): void
{
if (!$this->ReadPropertyBoolean('Debug')) {
return;
}
if (!is_string($message)) {
$message = json_encode($message, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
}
$this->SendDebug($title, (string) $message, 0);
}
}
+9 -6
View File
@@ -4,17 +4,17 @@ Unabhaengige Zusatzmodule fuer IP-Symcon. Dieses Repository ist nicht vom Enelix
## Status
Das Repository befindet sich im Aufbau. Verbrauchskostenreport,
Energiediagramm und Shelly Modul sind als installierbare IP-Symcon-Module
enthalten; die weiteren Module sind derzeit als Diskussionsentwürfe dokumentiert.
Das Repository befindet sich im Aufbau. Verbrauchskostenreport, Virtuelle
Batterie, CC100 Hardware, Energiediagramm, VGT-Schnittstelle und Shelly Modul
sind als installierbare IP-Symcon-Module enthalten.
## Module
- Verbrauchskostenreport (implementiert)
- Virtuelle Batterie
- CC100 Hardware
- Virtuelle Batterie (implementiert)
- CC100 Hardware (implementiert)
- Energiediagramm (implementiert)
- VGT-Schnittstelle
- VGT-Schnittstelle (implementiert)
- Shelly Modul (implementiert)
Die vollständigen Tabellen mit Properties, Variablen, Verhalten und offenen
@@ -59,9 +59,12 @@ Aktualisierungen werden ausschließlich über die IP-Symcon-Modulverwaltung
bezogen. Nach einem Branchwechsel oder Update müssen alle implementierten
Module geladen sein:
- `CC100 Hardware`
- `Energiediagramm`
- `Shelly Modul`
- `VGT-Schnittstelle`
- `Verbrauchskostenreport`
- `Virtuelle Batterie`
Ein korrekter Zustand zeigt die Repository-URL, den ausgewählten Branch und
kein Warnsymbol bei `Enelix Utils`.
+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);
}
}
+10
View File
@@ -0,0 +1,10 @@
# Virtuelle Batterie
Installierbares IP-Symcon-Modul fuer Enelix Utils ab Version 8.0.
Es aggregiert mehrere physische Batterien, reserviert einen priorisierten
SDL-Anteil und stellt dem vorhandenen Enelix-EMS-Batteriemodul generische
Proxyregister fuer den Eigenverbrauch bereit.
Konfiguration, Verdrahtung und Sicherheitsverhalten stehen in
[`docs/module/Virtuelle-Batterie/README.md`](../docs/module/Virtuelle-Batterie/README.md).
+167
View File
@@ -0,0 +1,167 @@
{
"elements": [
{
"type": "Label",
"caption": "Fasst mehrere physische Batterien zu einer virtuellen Batterie zusammen. SDL hat bei der Leistungsverteilung Vorrang."
},
{
"type": "List",
"name": "Batterieliste",
"caption": "Physische Batterien",
"rowCount": 5,
"add": true,
"delete": true,
"columns": [
{
"caption": "Name",
"name": "Name",
"width": "150px",
"add": "",
"edit": {"type": "ValidationTextBox"}
},
{
"caption": "Kapazitaet",
"name": "Kapazitaet_kWh",
"width": "110px",
"suffix": " kWh",
"add": 0,
"edit": {"type": "NumberSpinner", "minimum": 0, "digits": 2}
},
{
"caption": "Max. Laden",
"name": "MaxLaden_W",
"width": "110px",
"suffix": " W",
"add": 0,
"edit": {"type": "NumberSpinner", "minimum": 0, "digits": 0}
},
{
"caption": "Max. Entladen",
"name": "MaxEntladen_W",
"width": "120px",
"suffix": " W",
"add": 0,
"edit": {"type": "NumberSpinner", "minimum": 0, "digits": 0}
},
{
"caption": "Ladezustand",
"name": "LadezustandVariableID",
"width": "130px",
"add": 0,
"edit": {"type": "SelectVariable"}
},
{
"caption": "Istleistung",
"name": "IstleistungVariableID",
"width": "130px",
"add": 0,
"edit": {"type": "SelectVariable"}
},
{
"caption": "Sollleistung",
"name": "SollleistungVariableID",
"width": "140px",
"add": 0,
"edit": {"type": "SelectVariable"}
}
],
"values": []
},
{
"type": "ExpansionPanel",
"caption": "SDL-Reserve",
"items": [
{
"type": "NumberSpinner",
"name": "SDLReserveLaden",
"caption": "Reservierte SDL-Ladeleistung",
"suffix": " W",
"minimum": 0
},
{
"type": "NumberSpinner",
"name": "SDLReserveEntladen",
"caption": "Reservierte SDL-Entladeleistung",
"suffix": " W",
"minimum": 0
},
{
"type": "NumberSpinner",
"name": "Reservezeit",
"caption": "SDL-Reservezeit",
"suffix": " h",
"minimum": 0,
"maximum": 24,
"digits": 2
}
]
},
{
"type": "ExpansionPanel",
"caption": "Regelung und Sicherheit",
"items": [
{
"type": "NumberSpinner",
"name": "Aktualisierungsintervall",
"caption": "Aktualisierungsintervall",
"suffix": " s",
"minimum": 1
},
{
"type": "NumberSpinner",
"name": "MesswertMaxAlter",
"caption": "Maximales Messwertalter",
"suffix": " s",
"minimum": 1
},
{
"type": "NumberSpinner",
"name": "VorgabeTimeout",
"caption": "Timeout fuer EV- und SDL-Vorgaben",
"suffix": " s",
"minimum": 1
},
{
"type": "NumberSpinner",
"name": "EigenverbrauchStart",
"caption": "Startwert Eigenverbrauchskonto",
"suffix": " %",
"minimum": 0,
"maximum": 100,
"digits": 1
},
{
"type": "NumberSpinner",
"name": "SDLStart",
"caption": "Startwert SDL-Konto",
"suffix": " %",
"minimum": 0,
"maximum": 100,
"digits": 1
},
{
"type": "CheckBox",
"name": "DiagnoseAnzeigen",
"caption": "Diagnosevariablen anzeigen"
},
{
"type": "CheckBox",
"name": "LoggingEin",
"caption": "Debug-Logging aktivieren"
}
]
}
],
"actions": [
{
"type": "Button",
"caption": "Virtuelle Konten auf Startwerte zuruecksetzen",
"onClick": "IPS_RequestAction($id, 'KontenReset', true);"
},
{
"type": "Button",
"caption": "Jetzt aktualisieren",
"onClick": "IPS_RequestAction($id, 'Aktualisieren', 0);"
}
]
}
@@ -0,0 +1,220 @@
<?php
declare(strict_types=1);
namespace Belevo\EnelixUtils;
use InvalidArgumentException;
final class VirtuelleBatterieRegler
{
/**
* @param list<array{
* Index:int,
* Name:string,
* Kapazitaet_kWh:float,
* MaxLaden_W:float,
* MaxEntladen_W:float,
* Ladezustand_Prozent:float,
* Istleistung_W:float
* }> $batterien
* @return array<string, float>
*/
public static function aggregiere(
array $batterien,
float $sdlReserveLadenW,
float $sdlReserveEntladenW,
float $reservezeitH
): array {
if ($sdlReserveLadenW < 0.0
|| $sdlReserveEntladenW < 0.0
|| $reservezeitH < 0.0
) {
throw new InvalidArgumentException(
'SDL-Reserven und Reservezeit duerfen nicht negativ sein.'
);
}
$kapazitaet = 0.0;
$energie = 0.0;
$maxLaden = 0.0;
$maxEntladen = 0.0;
$istleistung = 0.0;
foreach ($batterien as $batterie) {
$kapazitaetKWh = (float) $batterie['Kapazitaet_kWh'];
$ladezustand = (float) $batterie['Ladezustand_Prozent'];
$ladenW = (float) $batterie['MaxLaden_W'];
$entladenW = (float) $batterie['MaxEntladen_W'];
if ($kapazitaetKWh <= 0.0 || $ladenW < 0.0 || $entladenW < 0.0) {
throw new InvalidArgumentException(
'Kapazitaet muss positiv und Leistungsgrenzen duerfen nicht negativ sein.'
);
}
if ($ladezustand < 0.0 || $ladezustand > 100.0) {
throw new InvalidArgumentException(
'Ladezustand muss zwischen 0 und 100 Prozent liegen.'
);
}
$kapazitaet += $kapazitaetKWh;
$energie += $kapazitaetKWh * $ladezustand / 100.0;
$maxLaden += $ladenW;
$maxEntladen += $entladenW;
$istleistung += (float) $batterie['Istleistung_W'];
}
if ($kapazitaet <= 0.0) {
throw new InvalidArgumentException(
'Mindestens eine verfuegbare Batterie ist erforderlich.'
);
}
$sdlLadenW = min($sdlReserveLadenW, $maxLaden);
$sdlEntladenW = min($sdlReserveEntladenW, $maxEntladen);
$untereReserve = $sdlEntladenW * $reservezeitH / 1000.0;
$obereReserve = $sdlLadenW * $reservezeitH / 1000.0;
if ($untereReserve + $obereReserve > $kapazitaet) {
throw new InvalidArgumentException(
'Die SDL-Energiereserven sind groesser als die Gesamtkapazitaet.'
);
}
return [
'Kapazitaet_kWh' => $kapazitaet,
'Energie_kWh' => $energie,
'Ladezustand_Prozent' => $energie / $kapazitaet * 100.0,
'Istleistung_W' => $istleistung,
'MaxLaden_W' => $maxLaden,
'MaxEntladen_W' => $maxEntladen,
'SDLKapazitaet_kWh' => $untereReserve + $obereReserve,
'EigenverbrauchKapazitaet_kWh' =>
$kapazitaet - $untereReserve - $obereReserve,
'SDLMaxLaden_W' => $sdlLadenW,
'SDLMaxEntladen_W' => $sdlEntladenW,
'EigenverbrauchMaxLaden_W' => max(0.0, $maxLaden - $sdlLadenW),
'EigenverbrauchMaxEntladen_W' => max(0.0, $maxEntladen - $sdlEntladenW),
'UntereSDLReserve_kWh' => $untereReserve,
'ObereSDLReserve_kWh' => $obereReserve,
];
}
/**
* SDL wird zuerst begrenzt. Eigenverbrauch erhaelt die verbleibende
* physische Leistung derselben Richtung.
*
* @param array<string, float> $aggregat
* @return array{Eigenverbrauch_W:float, SDL_W:float, Gesamt_W:float}
*/
public static function kombiniere(
float $eigenverbrauchW,
float $sdlW,
array $aggregat
): array {
$sdl = self::begrenze(
$sdlW,
(float) $aggregat['SDLMaxLaden_W'],
(float) $aggregat['SDLMaxEntladen_W']
);
$ev = self::begrenze(
$eigenverbrauchW,
(float) $aggregat['EigenverbrauchMaxLaden_W'],
(float) $aggregat['EigenverbrauchMaxEntladen_W']
);
if ($sdl > 0.0 && $ev > 0.0) {
$ev = min($ev, max(0.0, (float) $aggregat['MaxLaden_W'] - $sdl));
} elseif ($sdl < 0.0 && $ev < 0.0) {
$ev = max($ev, -max(
0.0,
(float) $aggregat['MaxEntladen_W'] - abs($sdl)
));
}
$gesamt = self::begrenze(
$sdl + $ev,
(float) $aggregat['MaxLaden_W'],
(float) $aggregat['MaxEntladen_W']
);
return [
'Eigenverbrauch_W' => $ev,
'SDL_W' => $sdl,
'Gesamt_W' => $gesamt,
];
}
/**
* @param list<array{
* Index:int,
* Name:string,
* Kapazitaet_kWh:float,
* MaxLaden_W:float,
* MaxEntladen_W:float,
* Ladezustand_Prozent:float,
* Istleistung_W:float
* }> $batterien
* @return array<int, float>
*/
public static function verteile(float $gesamtW, array $batterien): array
{
$ergebnis = [];
foreach ($batterien as $batterie) {
$ergebnis[(int) $batterie['Index']] = 0.0;
}
if (abs($gesamtW) < 0.5) {
return $ergebnis;
}
$laden = $gesamtW > 0.0;
usort(
$batterien,
static function (array $a, array $b) use ($laden): int {
$vergleich = (float) $a['Ladezustand_Prozent']
<=> (float) $b['Ladezustand_Prozent'];
return $laden ? $vergleich : -$vergleich;
}
);
$rest = abs($gesamtW);
foreach ($batterien as $batterie) {
if ($rest < 0.5) {
break;
}
$grenze = $laden
? (float) $batterie['MaxLaden_W']
: (float) $batterie['MaxEntladen_W'];
$anteil = min($rest, max(0.0, $grenze));
$ergebnis[(int) $batterie['Index']] = $laden ? $anteil : -$anteil;
$rest -= $anteil;
}
return $ergebnis;
}
/**
* Die SDL-Leistung wird bilanziell zuerst bedient. Die Differenz zur
* gemessenen Gesamtleistung wird dem Eigenverbrauch zugeordnet.
*
* @return array{Eigenverbrauch_W:float, SDL_W:float}
*/
public static function ordneIstleistungZu(
float $gesamtIstW,
float $sdlAuftragW
): array {
return [
'Eigenverbrauch_W' => $gesamtIstW - $sdlAuftragW,
'SDL_W' => $sdlAuftragW,
];
}
private static function begrenze(
float $leistungW,
float $maxLadenW,
float $maxEntladenW
): float {
return min($maxLadenW, max(-$maxEntladenW, $leistungW));
}
}
+12
View File
@@ -0,0 +1,12 @@
{
"id": "{5B2A199B-2226-411F-B83B-FC1CBA58496B}",
"name": "Virtuelle Batterie",
"type": 3,
"vendor": "Enelix",
"aliases": [],
"parentRequirements": [],
"childRequirements": [],
"implemented": [],
"prefix": "ENLVB",
"url": ""
}
+781
View File
@@ -0,0 +1,781 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/libs/VirtuelleBatterieRegler.php';
use Belevo\EnelixUtils\VirtuelleBatterieRegler;
class VirtuelleBatterie extends IPSModule
{
private const STATUS_AKTIV = 102;
private const STATUS_INAKTIV = 104;
private const STATUS_KONFIGURATION_UNGUELTIG = 201;
private const STATUS_MESSWERTE_UNGUELTIG = 202;
private const STATUS_AUSGABE_FEHLER = 203;
private const VM_UPDATE = 10603;
public function Create(): void
{
parent::Create();
$this->RegisterPropertyString('Batterieliste', '[]');
$this->RegisterPropertyInteger('SDLReserveLaden', 0);
$this->RegisterPropertyInteger('SDLReserveEntladen', 0);
$this->RegisterPropertyFloat('Reservezeit', 0.5);
$this->RegisterPropertyInteger('Aktualisierungsintervall', 2);
$this->RegisterPropertyInteger('MesswertMaxAlter', 30);
$this->RegisterPropertyInteger('VorgabeTimeout', 30);
$this->RegisterPropertyFloat('EigenverbrauchStart', 50.0);
$this->RegisterPropertyFloat('SDLStart', 50.0);
$this->RegisterPropertyBoolean('DiagnoseAnzeigen', false);
$this->RegisterPropertyBoolean('LoggingEin', false);
$this->RegisterVariableBoolean('Aktiv', 'Aktiv', '~Switch', 10);
$this->EnableAction('Aktiv');
$this->RegisterVariableInteger(
'EVManagement',
'EV Managementregister',
'',
20
);
$this->EnableAction('EVManagement');
$this->RegisterVariableInteger('EVModus', 'EV Modusregister', '', 30);
$this->EnableAction('EVModus');
$this->RegisterVariableFloat(
'EVLadeleistung',
'EV Laderegister',
'',
40
);
$this->EnableAction('EVLadeleistung');
$this->RegisterVariableFloat(
'EVEntladeleistung',
'EV Entladeregister',
'',
50
);
$this->EnableAction('EVEntladeleistung');
$this->RegisterVariableFloat(
'SDLSollleistung',
'SDL Sollleistung',
'',
60
);
$this->EnableAction('SDLSollleistung');
$this->RegisterVariableFloat(
'GesamtLadezustand',
'Gesamtladezustand',
'',
100
);
$this->RegisterVariableFloat(
'GesamtIstleistung',
'Gesamte Istleistung',
'',
110
);
$this->RegisterVariableFloat(
'GesamtMaxLaden',
'Gesamte maximale Ladeleistung',
'',
120
);
$this->RegisterVariableFloat(
'GesamtMaxEntladen',
'Gesamte maximale Entladeleistung',
'',
130
);
$this->RegisterVariableFloat(
'EigenverbrauchLadezustand',
'Eigenverbrauch Ladezustand',
'',
200
);
$this->RegisterVariableFloat(
'EigenverbrauchSollleistung',
'Eigenverbrauch Sollleistung',
'',
210
);
$this->RegisterVariableFloat(
'EigenverbrauchIstleistung',
'Eigenverbrauch Istleistung',
'',
220
);
$this->RegisterVariableFloat(
'EigenverbrauchMaxLaden',
'Eigenverbrauch maximal Laden',
'',
230
);
$this->RegisterVariableFloat(
'EigenverbrauchMaxEntladen',
'Eigenverbrauch maximal Entladen',
'',
240
);
$this->RegisterVariableFloat(
'SDLLadezustand',
'SDL Ladezustand',
'',
300
);
$this->RegisterVariableFloat(
'SDLIstleistung',
'SDL Istleistung',
'',
310
);
$this->RegisterVariableFloat(
'SDLMaxLaden',
'SDL maximal Laden',
'',
320
);
$this->RegisterVariableFloat(
'SDLMaxEntladen',
'SDL maximal Entladen',
'',
330
);
$this->RegisterVariableBoolean('Stoerung', 'Stoerung', '~Switch', 400);
$this->RegisterVariableString('Stoertext', 'Stoertext', '', 410);
$this->RegisterVariableString(
'Verteilstatus',
'Verteilstatus',
'',
420
);
$this->RegisterAttributeString('RegistrierteMesswerte', '[]');
$this->RegisterAttributeString('LetzteZielwerte', '{}');
$this->RegisterAttributeFloat('EigenverbrauchEnergie_kWh', 0.0);
$this->RegisterAttributeFloat('SDLEnergie_kWh', 0.0);
$this->RegisterAttributeBoolean('KontenInitialisiert', false);
$this->RegisterAttributeInteger('LetzteIntegration', 0);
$this->RegisterAttributeInteger('LetzteEVVorgabe', 0);
$this->RegisterAttributeInteger('LetzteSDLVorgabe', 0);
$this->RegisterAttributeFloat('EigenverbrauchAuftrag_W', 0.0);
$this->RegisterTimer(
'Aktualisierung',
0,
'IPS_RequestAction($_IPS["TARGET"], "Aktualisieren", 0);'
);
$this->RegisterTimer(
'EVUebernahme',
0,
'IPS_RequestAction($_IPS["TARGET"], "EVUebernehmen", 0);'
);
}
public function ApplyChanges(): void
{
parent::ApplyChanges();
$this->SetTimerInterval('Aktualisierung', 0);
$this->SetTimerInterval('EVUebernahme', 0);
$this->registriereMesswertmeldungen();
try {
$this->pruefeKonfiguration();
$this->SetTimerInterval(
'Aktualisierung',
$this->ReadPropertyInteger('Aktualisierungsintervall') * 1000
);
$this->aktualisiere();
} catch (Throwable $fehler) {
$this->sichererZustand($fehler->getMessage());
$this->SetStatus(self::STATUS_KONFIGURATION_UNGUELTIG);
}
}
public function MessageSink($zeitstempel, $senderID, $nachricht, $daten): void
{
if ((int) $nachricht !== self::VM_UPDATE) {
return;
}
$ids = json_decode(
$this->ReadAttributeString('RegistrierteMesswerte'),
true
);
if (is_array($ids) && in_array((int) $senderID, $ids, true)) {
$this->aktualisiere();
}
}
public function RequestAction($ident, $wert): void
{
switch ($ident) {
case 'Aktiv':
$this->SetValue('Aktiv', (bool) $wert);
if (!(bool) $wert) {
$this->WriteAttributeFloat('EigenverbrauchAuftrag_W', 0.0);
$this->SetValue('SDLSollleistung', 0.0);
$this->sichererZustand('');
$this->SetStatus(self::STATUS_INAKTIV);
return;
}
$this->aktualisiere();
return;
case 'EVManagement':
$this->SetValue('EVManagement', (int) $wert);
$this->uebernehmeEVVorgabe();
return;
case 'EVModus':
$this->SetValue('EVModus', (int) $wert);
$this->planeEVUebernahme();
return;
case 'EVLadeleistung':
$this->SetValue(
'EVLadeleistung',
max(0.0, (float) $wert)
);
$this->planeEVUebernahme();
return;
case 'EVEntladeleistung':
$this->SetValue(
'EVEntladeleistung',
max(0.0, (float) $wert)
);
$this->planeEVUebernahme();
return;
case 'SDLSollleistung':
$this->SetValue('SDLSollleistung', (float) $wert);
$this->WriteAttributeInteger('LetzteSDLVorgabe', time());
$this->aktualisiere();
return;
case 'EVUebernehmen':
$this->SetTimerInterval('EVUebernahme', 0);
$this->uebernehmeEVVorgabe();
return;
case 'Aktualisieren':
$this->aktualisiere();
return;
case 'KontenReset':
$this->WriteAttributeBoolean('KontenInitialisiert', false);
$this->WriteAttributeInteger('LetzteIntegration', time());
$this->aktualisiere();
return;
}
throw new InvalidArgumentException('Unbekannte Aktion: ' . $ident);
}
private function planeEVUebernahme(): void
{
$this->SetTimerInterval('EVUebernahme', 50);
}
private function uebernehmeEVVorgabe(): void
{
$management = (int) $this->GetValue('EVManagement');
$modus = (int) $this->GetValue('EVModus');
$ziel = 0.0;
if ($management === 1) {
$ziel = $modus === 1
? -(float) $this->GetValue('EVEntladeleistung')
: (float) $this->GetValue('EVLadeleistung');
}
$this->WriteAttributeFloat('EigenverbrauchAuftrag_W', $ziel);
$this->WriteAttributeInteger('LetzteEVVorgabe', time());
$this->SetValue('EigenverbrauchSollleistung', $ziel);
$this->aktualisiere();
}
private function aktualisiere(): void
{
$semaphore = 'EnelixVirtuelleBatterie_' . $this->InstanceID;
if (!IPS_SemaphoreEnter($semaphore, 1000)) {
return;
}
try {
if (!(bool) $this->GetValue('Aktiv')) {
$this->sichererZustand('');
$this->SetStatus(self::STATUS_INAKTIV);
return;
}
$this->pruefeKonfiguration();
[$batterien, $fehler] = $this->leseBatterien();
if ($batterien === []) {
throw new RuntimeException(
'Keine Batterie besitzt aktuelle gueltige Messwerte.'
);
}
$aggregat = VirtuelleBatterieRegler::aggregiere(
$batterien,
(float) $this->ReadPropertyInteger('SDLReserveLaden'),
(float) $this->ReadPropertyInteger('SDLReserveEntladen'),
$this->ReadPropertyFloat('Reservezeit')
);
$this->initialisiereKonten($aggregat);
$jetzt = time();
$timeout = $this->ReadPropertyInteger('VorgabeTimeout');
$ev = $jetzt - $this->ReadAttributeInteger('LetzteEVVorgabe')
<= $timeout
? $this->ReadAttributeFloat('EigenverbrauchAuftrag_W')
: 0.0;
$sdl = $jetzt - $this->ReadAttributeInteger('LetzteSDLVorgabe')
<= $timeout
? (float) $this->GetValue('SDLSollleistung')
: 0.0;
$auftrag = VirtuelleBatterieRegler::kombiniere(
$ev,
$sdl,
$aggregat
);
$verteilung = VirtuelleBatterieRegler::verteile(
$auftrag['Gesamt_W'],
$batterien
);
$this->schreibeVerteilung($verteilung);
$zuordnung = VirtuelleBatterieRegler::ordneIstleistungZu(
$aggregat['Istleistung_W'],
$auftrag['SDL_W']
);
$integrationsfehler = $this->integriereKonten(
$zuordnung,
$aggregat,
$jetzt
);
$this->SetValue(
'GesamtLadezustand',
$aggregat['Ladezustand_Prozent']
);
$this->SetValue(
'GesamtIstleistung',
$aggregat['Istleistung_W']
);
$this->SetValue('GesamtMaxLaden', $aggregat['MaxLaden_W']);
$this->SetValue(
'GesamtMaxEntladen',
$aggregat['MaxEntladen_W']
);
$this->SetValue(
'EigenverbrauchSollleistung',
$auftrag['Eigenverbrauch_W']
);
$this->SetValue(
'EigenverbrauchIstleistung',
$zuordnung['Eigenverbrauch_W']
);
$this->SetValue(
'EigenverbrauchMaxLaden',
$aggregat['EigenverbrauchMaxLaden_W']
);
$this->SetValue(
'EigenverbrauchMaxEntladen',
$aggregat['EigenverbrauchMaxEntladen_W']
);
$this->SetValue('SDLIstleistung', $zuordnung['SDL_W']);
$this->SetValue('SDLMaxLaden', $aggregat['SDLMaxLaden_W']);
$this->SetValue(
'SDLMaxEntladen',
$aggregat['SDLMaxEntladen_W']
);
$this->aktualisiereKontenanzeigen($aggregat);
$status = [
'Eigenverbrauch_W' => round($auftrag['Eigenverbrauch_W'], 1),
'SDL_W' => round($auftrag['SDL_W'], 1),
'Gesamt_W' => round($auftrag['Gesamt_W'], 1),
'Batterien' => count($batterien),
'Verteilung_W' => $verteilung,
];
$this->SetValue(
'Verteilstatus',
json_encode($status, JSON_THROW_ON_ERROR)
);
$fehler = array_values(array_filter(array_merge(
$fehler,
$integrationsfehler === '' ? [] : [$integrationsfehler]
)));
$this->setzeFehler(implode(' ', $fehler));
$this->SetStatus(
$fehler === []
? self::STATUS_AKTIV
: self::STATUS_MESSWERTE_UNGUELTIG
);
$this->protokolliere('Verteilung', $status);
} catch (InvalidArgumentException $fehler) {
$this->sichererZustand($fehler->getMessage());
$this->SetStatus(self::STATUS_KONFIGURATION_UNGUELTIG);
} catch (Throwable $fehler) {
$this->sichererZustand($fehler->getMessage());
$this->SetStatus(self::STATUS_AUSGABE_FEHLER);
} finally {
IPS_SemaphoreLeave($semaphore);
}
}
/**
* @return array{0:list<array<string, int|float|string>>, 1:list<string>}
*/
private function leseBatterien(): array
{
$konfiguration = $this->leseBatterieliste();
$batterien = [];
$fehler = [];
$maxAlter = $this->ReadPropertyInteger('MesswertMaxAlter');
foreach ($konfiguration as $index => $eintrag) {
$name = trim((string) ($eintrag['Name'] ?? ''));
if ($name === '') {
$name = 'Batterie ' . ($index + 1);
}
$socID = (int) ($eintrag['LadezustandVariableID'] ?? 0);
$leistungID = (int) ($eintrag['IstleistungVariableID'] ?? 0);
if (!$this->istAktuellerMesswert($socID, $maxAlter)
|| !$this->istAktuellerMesswert($leistungID, $maxAlter)
) {
$fehler[] = $name . ': Messwerte fehlen oder sind veraltet.';
continue;
}
$batterien[] = [
'Index' => $index,
'Name' => $name,
'Kapazitaet_kWh' =>
(float) ($eintrag['Kapazitaet_kWh'] ?? 0.0),
'MaxLaden_W' => (float) ($eintrag['MaxLaden_W'] ?? 0.0),
'MaxEntladen_W' =>
(float) ($eintrag['MaxEntladen_W'] ?? 0.0),
'Ladezustand_Prozent' => (float) GetValue($socID),
'Istleistung_W' => (float) GetValue($leistungID),
];
}
return [$batterien, $fehler];
}
/** @return list<array<string, mixed>> */
private function leseBatterieliste(): array
{
$liste = json_decode(
$this->ReadPropertyString('Batterieliste'),
true,
512,
JSON_THROW_ON_ERROR
);
if (!is_array($liste)) {
throw new InvalidArgumentException(
'Batterieliste muss eine JSON-Liste sein.'
);
}
return array_values($liste);
}
private function pruefeKonfiguration(): void
{
foreach ([
'Aktualisierungsintervall',
'MesswertMaxAlter',
'VorgabeTimeout',
] as $property) {
if ($this->ReadPropertyInteger($property) <= 0) {
throw new InvalidArgumentException(
$property . ' muss groesser als 0 sein.'
);
}
}
if ($this->ReadPropertyFloat('Reservezeit') < 0.0) {
throw new InvalidArgumentException(
'Reservezeit darf nicht negativ sein.'
);
}
$liste = $this->leseBatterieliste();
if ($liste === []) {
throw new InvalidArgumentException(
'Mindestens eine physische Batterie ist erforderlich.'
);
}
$zielIDs = [];
foreach ($liste as $index => $eintrag) {
$name = 'Batterie ' . ($index + 1);
if ((float) ($eintrag['Kapazitaet_kWh'] ?? 0.0) <= 0.0) {
throw new InvalidArgumentException(
$name . ': Kapazitaet muss groesser als 0 sein.'
);
}
foreach ([
'LadezustandVariableID',
'IstleistungVariableID',
] as $feld) {
$this->pruefeNumerischeVariable(
(int) ($eintrag[$feld] ?? 0),
$name . ' ' . $feld,
false
);
}
$zielID = (int) ($eintrag['SollleistungVariableID'] ?? 0);
$this->pruefeNumerischeVariable(
$zielID,
$name . ' SollleistungVariableID',
true
);
if (isset($zielIDs[$zielID])) {
throw new InvalidArgumentException(
'Jede Batterie benoetigt eine eigene Sollleistungsvariable.'
);
}
$zielIDs[$zielID] = true;
}
}
private function pruefeNumerischeVariable(
int $id,
string $name,
bool $aktion
): 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.');
}
if ($aktion
&& (int) ($variable['VariableAction'] ?? 0) <= 0
&& (int) ($variable['VariableCustomAction'] ?? 0) <= 0
) {
throw new InvalidArgumentException(
$name . ' benoetigt eine Variablenaktion.'
);
}
}
private function istAktuellerMesswert(int $id, int $maxAlter): bool
{
if ($id <= 0 || !IPS_VariableExists($id)) {
return false;
}
$variable = IPS_GetVariable($id);
if (!in_array((int) $variable['VariableType'], [1, 2], true)) {
return false;
}
$aktualisiert = (int) ($variable['VariableUpdated'] ?? 0);
return $aktualisiert > 0 && time() - $aktualisiert <= $maxAlter;
}
/** @param array<int, float> $verteilung */
private function schreibeVerteilung(array $verteilung): void
{
$alt = json_decode(
$this->ReadAttributeString('LetzteZielwerte'),
true
);
if (!is_array($alt)) {
$alt = [];
}
$neu = [];
foreach ($this->leseBatterieliste() as $index => $eintrag) {
$zielID = (int) ($eintrag['SollleistungVariableID'] ?? 0);
$ziel = (float) ($verteilung[$index] ?? 0.0);
$neu[(string) $zielID] = $ziel;
if (!isset($alt[(string) $zielID])
|| abs((float) $alt[(string) $zielID] - $ziel) >= 0.5
) {
RequestAction($zielID, $ziel);
}
}
$this->WriteAttributeString(
'LetzteZielwerte',
json_encode($neu, JSON_THROW_ON_ERROR)
);
}
private function sichererZustand(string $fehler): void
{
try {
$this->schreibeVerteilung([]);
} catch (Throwable $ausgabeFehler) {
if ($fehler === '') {
$fehler = $ausgabeFehler->getMessage();
} else {
$fehler .= ' ' . $ausgabeFehler->getMessage();
}
}
$this->SetValue('EigenverbrauchSollleistung', 0.0);
$this->SetValue('EigenverbrauchIstleistung', 0.0);
$this->SetValue('SDLIstleistung', 0.0);
$this->setzeFehler($fehler);
}
/** @param array<string, float> $aggregat */
private function initialisiereKonten(array $aggregat): void
{
if ($this->ReadAttributeBoolean('KontenInitialisiert')) {
return;
}
$evProzent = min(
100.0,
max(0.0, $this->ReadPropertyFloat('EigenverbrauchStart'))
);
$sdlProzent = min(
100.0,
max(0.0, $this->ReadPropertyFloat('SDLStart'))
);
$this->WriteAttributeFloat(
'EigenverbrauchEnergie_kWh',
$aggregat['EigenverbrauchKapazitaet_kWh'] * $evProzent / 100.0
);
$this->WriteAttributeFloat(
'SDLEnergie_kWh',
$aggregat['SDLKapazitaet_kWh'] * $sdlProzent / 100.0
);
$this->WriteAttributeInteger('LetzteIntegration', time());
$this->WriteAttributeBoolean('KontenInitialisiert', true);
}
/**
* @param array{Eigenverbrauch_W:float, SDL_W:float} $zuordnung
* @param array<string, float> $aggregat
*/
private function integriereKonten(
array $zuordnung,
array $aggregat,
int $jetzt
): string {
$letzteZeit = $this->ReadAttributeInteger('LetzteIntegration');
$this->WriteAttributeInteger('LetzteIntegration', $jetzt);
if ($letzteZeit <= 0 || $jetzt <= $letzteZeit) {
return '';
}
$dauer = $jetzt - $letzteZeit;
$maxLuecke = max(
$this->ReadPropertyInteger('MesswertMaxAlter'),
$this->ReadPropertyInteger('Aktualisierungsintervall') * 3
);
if ($dauer > $maxLuecke) {
return 'Integrationsluecke erkannt; Konten wurden nicht fortgeschrieben.';
}
$evKapazitaet = $aggregat['EigenverbrauchKapazitaet_kWh'];
$sdlKapazitaet = $aggregat['SDLKapazitaet_kWh'];
$evEnergie = $this->ReadAttributeFloat('EigenverbrauchEnergie_kWh')
+ $zuordnung['Eigenverbrauch_W'] * $dauer / 3600000.0;
$sdlEnergie = $this->ReadAttributeFloat('SDLEnergie_kWh')
+ $zuordnung['SDL_W'] * $dauer / 3600000.0;
$this->WriteAttributeFloat(
'EigenverbrauchEnergie_kWh',
min($evKapazitaet, max(0.0, $evEnergie))
);
$this->WriteAttributeFloat(
'SDLEnergie_kWh',
min($sdlKapazitaet, max(0.0, $sdlEnergie))
);
return '';
}
/** @param array<string, float> $aggregat */
private function aktualisiereKontenanzeigen(array $aggregat): void
{
$evKapazitaet = $aggregat['EigenverbrauchKapazitaet_kWh'];
$sdlKapazitaet = $aggregat['SDLKapazitaet_kWh'];
$this->SetValue(
'EigenverbrauchLadezustand',
$evKapazitaet > 0.0
? $this->ReadAttributeFloat('EigenverbrauchEnergie_kWh')
/ $evKapazitaet * 100.0
: 0.0
);
$this->SetValue(
'SDLLadezustand',
$sdlKapazitaet > 0.0
? $this->ReadAttributeFloat('SDLEnergie_kWh')
/ $sdlKapazitaet * 100.0
: 0.0
);
}
private function registriereMesswertmeldungen(): void
{
$alteIDs = json_decode(
$this->ReadAttributeString('RegistrierteMesswerte'),
true
);
if (!is_array($alteIDs)) {
$alteIDs = [];
}
$neueIDs = [];
try {
foreach ($this->leseBatterieliste() as $eintrag) {
foreach ([
'LadezustandVariableID',
'IstleistungVariableID',
] as $feld) {
$id = (int) ($eintrag[$feld] ?? 0);
if ($id > 0 && IPS_VariableExists($id)) {
$neueIDs[] = $id;
}
}
}
} catch (Throwable $fehler) {
$neueIDs = [];
}
$neueIDs = array_values(array_unique($neueIDs));
foreach ($alteIDs as $id) {
if (!in_array((int) $id, $neueIDs, true)) {
$this->UnregisterMessage((int) $id, self::VM_UPDATE);
}
}
foreach ($neueIDs as $id) {
if (!in_array($id, $alteIDs, true)) {
$this->RegisterMessage($id, self::VM_UPDATE);
}
}
$this->WriteAttributeString(
'RegistrierteMesswerte',
json_encode($neueIDs, JSON_THROW_ON_ERROR)
);
}
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);
}
}
+125 -18
View File
@@ -1,29 +1,136 @@
# CC100 Hardware
> Status: Diskussionsentwurf. Keine EMS-Abhängigkeit.
> Status: Implementiert fuer den WAGO Compact Controller 100, Artikel 751-9301,
> mit IP-Symcon ab Version 8.0.
Stellt nur ausgewählte DI-, DO-, PT-, AI- und AO-Kanäle bereit. Kanal,
Anzeigename, Aktivierung und Abfrageintervall werden je Eintrag festgelegt.
Das Modul bindet die komplette Onboard-I/O des CC100 direkt ueber die von WAGO
dokumentierten Linux-Schnittstellen an:
## Variablen
- 8 digitale Eingaenge (DI)
- 4 digitale Ausgaenge (DO)
- 2 analoge Eingaenge 0 bis 10 V (AI)
- 2 analoge Ausgaenge 0 bis 10 V (AO)
- 2 PT1000-Temperatureingaenge (PT)
| Ident | Typ / Zugriff | Beschreibung |
| --- | --- | --- |
| Dynamisch je Kanal | passend / Anzeige oder bedienbar | Nur ausgewählte Kanäle; DO/AO erst nach geprüfter Zuordnung bedienbar. |
| `Hardwarestatus` | Integer / Anzeige | Anschlussstatus. |
| `Stoertext` | String / Anzeige | Zugriffs- oder Messfehler. |
## Kanalauswahl
Die Property 'Kanalauswahl' enthaelt alle 18 Kanaele. Pro Zeile werden
Aktivierung, Typ, Kanalnummer, Anzeigename und Abfrageintervall festgelegt.
Nur aktive Kanaele werden als IP-Symcon-Variablen angelegt. Wird ein Kanal
deaktiviert, entfernt das Modul seine dynamische Variable beim naechsten
Uebernehmen der Konfiguration.
DO- und AO-Variablen sind bedienbar. DI, AI und PT sind reine Messwerte.
AI und AO werden in Volt dargestellt, PT in Grad Celsius.
## Properties
| Ident | Typ | Standard / Beschreibung |
| Property | Standard | Bedeutung |
| --- | --- | --- |
| `Kanalauswahl` | String/JSON | `[]`; Kanalart, Nummer, Anzeigename, Aktiv und Abfrageintervall in ms je Zeile. |
| `StartverhaltenAusgaenge` | Auswahl | `Lesen/uebernehmen` oder definierte Startwerte schreiben. |
| `StartwerteAusgaenge` | String/JSON | `{}`; nur bei ausdrücklich gewähltem Schreibmodus. |
| 'Kanalauswahl' | alle Kanaele inaktiv | Sichtbare und abgefragte Kanaele |
| 'DigitalInputPath' | WAGO-DI-Sammelpfad | 8-Bit-Eingangswert, LSB ist DI1 |
| 'DigitalOutputPath' | '/sys/kernel/dout_drv/DOUT_DATA' | 4-Bit-Ausgangswert, LSB ist DO1 |
| 'CalibrationPath' | '/etc/calib' | WAGO-Zweipunktkalibrierung |
| 'AI1Path', 'AI2Path' | WAGO-IIO-Pfade | Analoge Eingangswerte |
| 'AO1ValuePath', 'AO2ValuePath' | WAGO-IIO-Pfade | Analoge Ausgangswerte |
| 'AO1PowerPath', 'AO2PowerPath' | WAGO-IIO-Pfade | Freigabe der analogen Ausgaenge |
| 'PT1Path', 'PT2Path' | WAGO-IIO-Pfade | PT1000-Rohwerte |
| 'Debug' | 'false' | Zusaetzliche Diagnose im Debug-Log |
## Verhalten und offene Punkte
Die Hardwarepfade sind im Konfigurationsformular unter **Erweiterte
Hardwarepfade** sichtbar. Dadurch koennen Firmwarevarianten angepasst werden,
ohne den Modulcode zu aendern.
- Beim Start werden Ausgänge nicht ungeprüft überschrieben.
- Nur ausgewählte Kanäle werden angelegt, abgefragt oder bedient.
- CC100-Hardwarepfade, Skalierung und Schreibbarkeit von AI/AO prüfen.
- RS485-Schnittstelle bleibt ein eigener Prüfauftrag.
## Aktualisierung
Die CC100-Treiber stellen die Onboard-I/O als lesbare beziehungsweise
schreibbare Systemdateien bereit. Fuer diese Dateien ist von WAGO kein
zuverlaessiger Aenderungs-Eventkanal dokumentiert. Das Modul verwendet deshalb
einen gemeinsamen Timer:
1. Das kleinste aktive Kanalintervall bestimmt den Timer.
2. Pro Lauf werden nur faellige Kanaele verarbeitet.
3. DI beziehungsweise DO werden je Lauf nur einmal als Sammelwert gelesen.
4. IP-Symcon-Werte werden nur bei einer echten Wertaenderung geschrieben.
Das Intervall kann pro Kanal zwischen 100 ms und 3 600 000 ms eingestellt
werden. Kurze Intervalle erhoehen CPU- und I/O-Last.
## Kalibrierung
AI, AO und PT verwenden die individuellen Zweipunktwerte aus '/etc/calib'.
Das Modul akzeptiert die WAGO-Bezeichnungen 'AO1'/'AO2' sowie die in der
WAGO-Beispieldatei vorkommenden Schreibweisen 'A01'/'A02'.
- AI-Rohwerte werden kalibriert und von mV nach V umgerechnet.
- AO-Sollwerte werden von V nach mV und danach invers in den Rohwert umgerechnet.
- PT-Rohwerte werden zuerst in Ohm und anschliessend mit der
Callendar-Van-Dusen-Kennlinie in Grad Celsius umgerechnet.
## Ausgangssicherheit
'ApplyChanges()' schreibt keine Ausgaenge. Beim Start werden DO und AO nur
eingelesen. Ein DO-Schaltbefehl liest zuerst den gesamten Ausgangswert, aendert
genau das gewaehlte Bit und erhaelt die anderen Ausgaenge. AO akzeptiert nur
Werte von 0 bis 10 V und schreibt zuerst die WAGO-Freigabe, danach den
kalibrierten Rohwert.
Die rekursive Schreibschleife des Enelix-1-Moduls wurde verworfen. Ein
fehlgeschlagener Schreibzugriff blockiert IP-Symcon nicht bis zu fuenf Sekunden,
sondern liefert sofort Status '203' und einen Stoertext.
## Variablen
| Ident | Typ | Zugriff |
| --- | --- | --- |
| 'DI1' bis 'DI8' | Boolean | Anzeige, nur wenn aktiviert |
| 'DO1' bis 'DO4' | Boolean | Bedienbar, nur wenn aktiviert |
| 'AI1', 'AI2' | Float / V | Anzeige, nur wenn aktiviert |
| 'AO1', 'AO2' | Float / V | Bedienbar, nur wenn aktiviert |
| 'PT1', 'PT2' | Float / Grad Celsius | Anzeige, nur wenn aktiviert |
| 'Hardwarestatus' | Integer | '0' unkonfiguriert, '1' bereit, '2' Lesefehler, '3' Fehler |
| 'Stoertext' | String | Letzter zusammengefasster Fehler |
## Migration aus Enelix 1
### Unveraendert uebernommen
- vier DO, acht DI und zwei PT-Kanaele
- Bitreihenfolge mit DI1 beziehungsweise DO1 als LSB
- die vorhandenen WAGO-Systempfade als Standardwerte
### Angepasst
- getrennte feste Timer durch einen gruppierten, kanalspezifischen Poller ersetzt
- DO-Aktionen lesen den Sammelwert und aendern nur das gewaehlte Bit
- PT-Auswertung verwendet die individuelle Kalibrierung und Kennlinie
### Neu implementiert
- beide AI- und AO-Kanaele
- frei waehlbare Kanaele und dynamische Variablen
- konfigurierbare Intervalle und ueberschreibbare Hardwarepfade
- Status-, Fehler- und Debugdiagnose
### Verworfen
- rekursives Schreiben mit bis zu fuenf Sekunden Blockierzeit
- zyklisches Neuschreiben aller DO ohne neuen Schaltbefehl
- feste PT-Rohwertbereiche und nicht kalibrierte Skalierungsfaktoren
Das alte Modul kann nicht in-place aktualisiert werden. Fuer die Umstellung:
1. Vorhandene Ausgangszustaende und verwendete Kanaele dokumentieren.
2. Eine neue Instanz 'CC100 Hardware' anlegen.
3. Benoetigte Kanaele aktivieren und Anzeigenamen vergeben.
4. Hardwarepfade und Kalibrierdatei pruefen.
5. Zuerst nur Eingaenge testen.
6. Ausgaenge einzeln und unter Aufsicht freigeben.
## RS-485
RS-485 gehoert nicht zur Onboard-I/O-Abbildung dieses Moduls. Der CC100 stellt
die Schnittstelle als '/dev/ttySTM1' bereit. Protokoll, Teilnehmeradressen,
Baudrate, Paritaet, Registerbereiche und Zeitvorgaben gehoeren in eine separate
serielle beziehungsweise Modbus-Instanz. So bleibt die I/O-Abfrage unabhaengig
von langsamen oder fehlerhaften Busteilnehmern.
+7 -8
View File
@@ -1,20 +1,19 @@
# Modulübersicht Enelix Utils
> Status: Verbrauchskostenreport, Energiediagramm und Shelly Modul sind
> implementiert. Die übrigen Module sind als Diskussionsentwürfe dokumentiert.
> Status: Alle dokumentierten Enelix-Utils-Module sind implementiert.
| Modul | Herkunft | EMS-Abhängigkeit |
| --- | --- | --- |
| [Verbrauchskostenreport](Verbrauchskostenreport/README.md) | Kosten- und Verbrauchsauswertung | Keine |
| [Virtuelle Batterie](Virtuelle-Batterie/README.md) | Bat_EV_SDL_V4 | Optionaler Vertrag, keine Code-Abhängigkeit |
| [CC100 Hardware](CC100-Hardware/README.md) | CC100_HW | Keine |
| [Virtuelle Batterie](Virtuelle-Batterie/README.md) | Bat_EV_SDL_V4 (implementiert) | Optionaler Vertrag, keine Code-Abhängigkeit |
| [CC100 Hardware](CC100-Hardware/README.md) | CC100_HW (implementiert) | Keine |
| [Energiediagramm](Energiediagramm/README.md) | Energy_Pie (implementiert) | Keine |
| [VGT-Schnittstelle](VGT-Schnittstelle/README.md) | MQTTPVSDL + MQTTBatterySDL | Optional konfigurierbar |
| [VGT-Schnittstelle](VGT-Schnittstelle/README.md) | MQTTPVSDL + MQTTBatterySDL (implementiert) | Optional konfigurierbar |
| [Shelly Modul](Shelly-Modul/README.md) | Shelly_Parser_MQTT | Keine |
Utils-Module beeinflussen sich nicht gegenseitig. Prognose, Lizenzierung und
Störüberwachung sind keine Module dieses Repositories.
`0*` bezeichnet einen noch nicht eingerichteten Anlagenwert. Erst nach der
Teamfreigabe erhält ein Modul `module.json`, `form.json`, `module.php` und
zugehörige Unit-Tests.
`0*` bezeichnet einen noch nicht eingerichteten Anlagenwert. Jedes Modul
besitzt `module.json`, `form.json`, `module.php`, Unit-Tests und einen
registrierten IP-Symcon-Laufzeittest.
+92 -31
View File
@@ -1,42 +1,103 @@
# VGT-Schnittstelle
> Status: Diskussionsentwurf. Führt `MQTTPVSDL` und die vorhandenen
> Batterie-Felder aus `MQTTBatterySDL` zusammen.
> Status: Implementiert fuer IP-Symcon 8.0. Fuehrt `MQTTPVSDL` und
> `MQTTBatterySDL` zusammen.
Die bestehende MQTT-Arbeitsweise und technische Alt-Idents bleiben erhalten.
Die Geräteart PV oder Batterie steuert nur die sichtbaren Felder.
Die bestehende MQTT-Schnittstelle wurde unveraendert uebernommen. Weder
Topic-Namen noch Request- und Response-Felder wurden erweitert oder
umbenannt. Die Geraeteart bestimmt nur Regelverhalten und sichtbare Messwerte.
## Variablen
## Feste MQTT-Schnittstelle
| Technischer Ident | Typ / Zugriff | Beschreibung |
| Richtung | Topic |
| --- | --- |
| Lesen | `feedback-request/{TopicSuffix}` |
| Leseantwort | `feedback-response/{TopicSuffix}` |
| Steuern | `remote-control-request/{TopicSuffix}` |
| Steuerantwort | `remote-control-response/{TopicSuffix}` |
Ein Steuerauftrag verwendet weiterhin:
```json
{
"power_setpoint": 3500,
"strategy": "activate"
}
```
Die Steuerantwort enthaelt weiterhin ausschliesslich
`power_setpoint` und `strategy`.
Die Leseantwort enthaelt fuer PV:
- `power_production`
- `is_ready`
- `is_running`
Bei der Geraeteart Batterie kommen unveraendert hinzu:
- `state_of_charge`
- `min_soc`
- `max_soc`
MQTT wird weiterhin mit Pakettyp 3, QoS 0 und `Retain=false` ueber den
IP-Symcon-MQTT-Parent verwendet.
## Batterie und virtuelle Batterie
Im Batteriemodus wird `ReqActionID` auf die Variable `SDLSollleistung`
der virtuellen Batterie gelegt. `PowerProductionID` verweist auf
`SDLIstleistung`, `SoCID` auf `SDLLadezustand`.
Die VGT-Schnittstelle schreibt niemals physische Batterieregister. Die
virtuelle Batterie priorisiert den SDL-Auftrag und verteilt den gemeinsamen
Nettosollwert.
Die Vorzeichenumkehr von Enelix 1 bleibt erhalten: Bei `activate` wird der
empfangene Batterie-`power_setpoint` mit umgekehrtem Vorzeichen an die
Zielvariable uebergeben.
## Strategien
| Strategie | PV | Batterie |
| --- | --- | --- |
| `IsReady` | Boolean / Anzeige | Bestehender Bereitschaftsstatus. |
| `IsRunning` | Boolean / Anzeige | Bestehender Bearbeitungsstatus. |
| `MinSoC` | Float / Anzeige | Nur Batterie; untere Ladezustandsgrenze. |
| `MaxSoC` | Float / Anzeige | Nur Batterie; obere Ladezustandsgrenze. |
| `PowerSetpoint` | Integer / bedienbar | Bestehende Leistungsvorgabe und Testaktion. |
| `Strategy` | String / bedienbar | Bestehende Strategie und Testaktion. |
| `LastReadResponse` | String / Anzeige | Letzte Lese-Antwort. |
| `LastWriteResponse` | String / Anzeige | Letzte Steuer-Antwort. |
| `activate` | Setpoint zwischen 0 und Maximum | invertierter Setpoint |
| `stop` | Freigabe auf konfiguriertes Maximum | 0 W |
| `restore` | keine aktive Fernbegrenzung | Regelung auf `TargetSoC` |
Im Batteriemodus verhindern `MinSoC` und `MaxSoC` eine Vorgabe in die
falsche Richtung an der jeweiligen Ladezustandsgrenze.
## Properties
| Technischer Ident | Typ | Standard / Beschreibung |
| --- | --- | --- |
| `Geraeteart` | Auswahl | `PV`; alternativ `Batterie`. |
| `TopicSuffix` | String | leer; bestehender MQTT-Suffix. |
| `ReqActionID` | Integer | `0`; bestehende Ausgabevariable/Nennleistung. |
| `PowerProductionID` | Integer | `0`; aktuelle SDL-Leistung. |
| `SoCID` | Integer | `0`; nur bei Batterie. |
| `TargetSoC` | Float | `50` %; nur bei Batterie. |
| `ChargePower` | Integer | `2500` W; nur bei Batterie. |
| `DischargePower` | Integer | `2500` W; nur bei Batterie. |
| `MaxPowerSetpoint` | Integer | `10000` W. |
| Property | Standard | Beschreibung |
| --- | ---: | --- |
| `Geraeteart` | PV | PV oder Batterie |
| `TopicSuffix` | leer | Unveraenderter MQTT-Suffix |
| `ReqActionID` | 0 | Bedienbare Zielvariable |
| `PowerProductionID` | 0 | Aktuelle SDL-/PV-Leistung |
| `SoCID` | 0 | Ladezustand im Batteriemodus |
| `TargetSoC` | 50 % | Zielwert fuer `restore` |
| `ChargePower` | 2500 W | Ladeleistung fuer `restore` |
| `DischargePower` | 2500 W | Entladeleistung fuer `restore` |
| `MaxPowerSetpoint` | 10000 W | Begrenzung eingehender Setpoints |
| `LoggingEin` | false | Debug-Ausgaben |
## Verhalten und offene Punkte
## Robustheit
- MQTT-Parent, Auftragsauswertung, Timer, Aktionen und vorhandener Testknopf
bleiben fachlicher Ausgangspunkt.
- Passende MQTT-Verbindung zuordnen oder bei der Instanziierung anlegen.
- Keine neuen Zieladapter, Protokolle oder Datenqualitätsfelder in diesem Schritt.
- Optionale EMS-Anbindung darf keine feste Repository-Abhängigkeit erzeugen.
- Zielvariablen muessen numerisch und bedienbar sein.
- MQTT-Antworten werden in einer FIFO-Warteschlange verarbeitet; schnelle
parallele Anfragen ueberschreiben sich nicht mehr.
- Fehler der Zielaktion werden als Instanzstatus und Stoertext angezeigt.
- Ungueltige JSON-Nutzdaten werden ohne Hardwareaktion verworfen.
- MQTT-Payload und Topic-Vertrag bleiben dabei vollstaendig kompatibel.
## Inbetriebnahme
1. Vorhandenen MQTT-Parent zuordnen.
2. Geraeteart und bisherigen `TopicSuffix` uebernehmen.
3. Ziel- und Messvariablen konfigurieren.
4. Im Batteriemodus die Variablen der virtuellen Batterie verwenden.
5. `MinSoC`, `MaxSoC` und `TargetSoC` pruefen.
6. Zuerst `feedback-request`, danach `stop`, `activate` und
gegebenenfalls `restore` testen.
+105 -58
View File
@@ -1,68 +1,115 @@
# Virtuelle Batterie
> Status: Diskussionsentwurf. Übernimmt `Bat_EV_SDL_V4` für die Bereiche
> Eigenverbrauch und SDL.
> Status: Implementiert fuer IP-Symcon 8.0. Adaptiert das Grundkonzept aus
> `Bat_EV_SDL_V4`, ohne dessen herstellerspezifische Typnamen zu uebernehmen.
Batterien und VGT-Kommunikation werden ausgewählt. Das Modul bleibt ohne EMS
nutzbar; eine EMS-Anbindung verwendet optional denselben Nachrichtenvertrag,
ohne Code-Abhängigkeit zwischen den Repositories.
Das Modul fasst mehrere physische Batterien zu genau einer grossen virtuellen
Batterie zusammen. Diese Gesamtbatterie wird in einen Eigenverbrauchs- und
einen SDL-Anteil aufgeteilt. SDL hat bei der Leistungsverteilung Vorrang.
## Variablen
## Verantwortlichkeiten
| Ident | Typ / Zugriff | Beschreibung |
| --- | --- | --- |
| `Aktiv` | Boolean / bedienbar | Koordination aktivieren. |
| `EigenverbrauchSollleistung` | Float / bedingt bedienbar | W; nur bei lokaler Quelle schreiben. |
| `SDLSollleistung` | Float / Anzeige | W; nur aus gültigem zeitlichem Auftrag. |
| `EigenverbrauchIstleistung` | Float / Anzeige | W; zugeordneter Anteil. |
| `SDLIstleistung` | Float / Anzeige | W; zugeordneter Anteil. |
| `GesamtIstleistung` | Float / Anzeige | W; Summe gültiger physischer Messwerte. |
| `Leistungsquelle` | Integer / Anzeige | `0` fehlt, `1` berechnet, `2` gemessen. |
| `EigenverbrauchLadezustand` | Float / Anzeige | Virtueller Füllstand in %. |
| `SDLLadezustand` | Float / Anzeige | Virtueller Füllstand in %. |
| `EigenverbrauchMaxLaden` | Float / Anzeige | Verfügbare Ladeleistung in W. |
| `EigenverbrauchMaxEntladen` | Float / Anzeige | Verfügbare Entladeleistung in W. |
| `SDLMaxLaden` | Float / Anzeige | Verfügbare Ladeleistung in W. |
| `SDLMaxEntladen` | Float / Anzeige | Verfügbare Entladeleistung in W. |
| `EigenverbrauchStart` | Float / bedienbar | Startwert 0–100 % für explizites Rücksetzen. |
| `SDLStart` | Float / bedienbar | Startwert 0–100 % für explizites Rücksetzen. |
| `VirtuelleKontenRuecksetzen` | Boolean / Impuls | `true` löst aus und wird wieder zurückgesetzt. |
| `Managerstatus` | Integer / Anzeige | Nur bei Managerquelle. |
| `VGTStatus` | Integer / Anzeige | Nur bei VGT-Quelle. |
| `Stoerung` | Boolean / Anzeige | Eigene oder zugeordnete Fehler. |
| `Stoertext` | String / Anzeige | Diagnose. |
```text
Enelix Batterie-Modul -> EV-Proxyregister --+
+-> Virtuelle Batterie -> physische Sollwerte
VGT-Schnittstelle ----> SDLSollleistung ----+
```
Die virtuelle Batterie ist die einzige Instanz, die die konfigurierten
physischen Sollleistungsvariablen beschreibt. Das bestehende EMS-Batteriemodul
regelt den Eigenverbrauchsanteil. Die VGT-Schnittstelle regelt ausschliesslich
den SDL-Anteil.
Positive Leistung bedeutet Laden, negative Leistung bedeutet Entladen.
## Physische Batterien
Die Property `Batterieliste` enthaelt pro Batterie:
| Feld | Bedeutung |
| --- | --- |
| `Name` | Lesbare Kennung |
| `Kapazitaet_kWh` | Nutzbare Kapazitaet |
| `MaxLaden_W` | Maximale Ladeleistung |
| `MaxEntladen_W` | Maximale Entladeleistung |
| `LadezustandVariableID` | Aktueller Ladezustand in Prozent |
| `IstleistungVariableID` | Gemessene Leistung in W |
| `SollleistungVariableID` | Bedienbare Zielvariable mit Vorzeichen |
Der Gesamtladezustand ist kapazitaetsgewichtet. Beim Laden werden Batterien mit
tieferem Ladezustand zuerst verwendet, beim Entladen Batterien mit hoeherem
Ladezustand.
## Anschluss des bestehenden Batteriemoduls
Eine Enelix-EMS-Instanz **Batterie** wird als herstellerunabhaengige, von Enelix
gesteuerte Batterie eingerichtet. Ihre Messwerte und Register werden auf die
Variablen der virtuellen Batterie gelegt:
| Property im EMS-Batteriemodul | Variable der virtuellen Batterie |
| --- | --- |
| `MaxLadeleistungVariableID` | `EigenverbrauchMaxLaden` |
| `MaxEntladeleistungVariableID` | `EigenverbrauchMaxEntladen` |
| `LadezustandVariableID` | `EigenverbrauchLadezustand` |
| `IstleistungVariableID` | `EigenverbrauchIstleistung` |
| `ManagementRegisterVariableID` | `EVManagement` |
| `ModusRegisterVariableID` | `EVModus` |
| `LadeleistungRegisterVariableID` | `EVLadeleistung` |
| `EntladeleistungRegisterVariableID` | `EVEntladeleistung` |
Die Netzleistungsvariable bleibt die reale Messung des Anlagenanschlusspunktes.
Das Batteriemodul schreibt die vier Proxyregister atomar in seiner bestehenden
Reihenfolge. Erst das Managementregister uebernimmt den vollstaendigen
Eigenverbrauchsauftrag.
## Anschluss der VGT-Schnittstelle
| Property der VGT-Schnittstelle | Variable der virtuellen Batterie |
| --- | --- |
| `ReqActionID` | `SDLSollleistung` |
| `PowerProductionID` | `SDLIstleistung` |
| `SoCID` | `SDLLadezustand` |
Damit erreicht kein MQTT-Befehl direkt eine physische Batterie.
## Properties
| Ident | Typ | Standard / Beschreibung |
| --- | --- | --- |
| `Batterieliste` | String/JSON | `[]`; Zielstelle, Kapazität, Grenzen und Messquellen je Batterie. |
| `Eigenverbrauchsquelle` | Integer | `0` lokal, `1` externe Variable, `2` Managervertrag. |
| `EigenverbrauchSollVariableID` | Integer | `0`; Pflicht bei Quelle 1. |
| `SDLQuelle` | Integer | `0` keine, `1` VGT-Instanz, `2` externer Auftragsdatensatz. |
| `VGTInstanzID` | Integer | `0`; Pflicht bei SDLQuelle 1. |
| `SDLAuftragVariableID` | Integer | `0`; Pflicht bei Quelle 2, inklusive Gültigkeit. |
| `SDLReserveLaden` | Float | `0` W. |
| `SDLReserveEntladen` | Float | `0` W. |
| `Reservezeit` | Float | `0.5` h; Bereich 0–24. |
| `Aktualisierungsintervall` | Integer | `2` s. |
| `MesswertMaxAlter` | Integer | `30` s. |
| `Meldeintervall` | Integer | `10` s; bei Vertragsanbindung. |
| `VorgabeTimeout` | Integer | `30` s; bei weitergeleiteten Vorgaben. |
| `AusfallEigenverbrauch` | Integer | `0` auf 0 begrenzen, `1` lokale begrenzte Vorgabe. |
| `AusfallEigenverbrauchLeistung` | Float | `0` W; nur bei Ausfallmodus 1. |
| `FilterAktiv` | Boolean | `true`. |
| `FilterToleranz` | Float | `15` %. |
| `FilterRampe` | Float | `2000` W/s. |
| `FilterTreffer` | Integer | `1`. |
| `Abgleichintervall` | Float | `6` h. |
| `Abgleichtoleranz` | Float | `2` %. |
| `DiagnoseAnzeigen` | Boolean | `false`. |
| `PrioritaetPV` | Integer | `0`; nur bei Manageranbindung. |
| `PrioritaetPeak` | Integer | `0`; nur bei Manageranbindung. |
| Property | Standard | Beschreibung |
| --- | ---: | --- |
| `Batterieliste` | `[]` | Physische Batterien und Zielvariablen |
| `SDLReserveLaden` | 0 W | Fuer SDL reservierte Ladeleistung |
| `SDLReserveEntladen` | 0 W | Fuer SDL reservierte Entladeleistung |
| `Reservezeit` | 0.5 h | Energetische Reserve je Richtung |
| `Aktualisierungsintervall` | 2 s | Zyklische Sicherheitsaktualisierung |
| `MesswertMaxAlter` | 30 s | Zulaessiges Alter physischer Messwerte |
| `VorgabeTimeout` | 30 s | Gueltigkeit der EV- und SDL-Vorgaben |
| `EigenverbrauchStart` | 50 % | Startwert des EV-Kontos |
| `SDLStart` | 50 % | Startwert des SDL-Kontos |
| `LoggingEin` | false | Debug-Ausgaben |
## Verhalten und offene Punkte
## Verhalten und Sicherheit
Eigenverbrauch und SDL ergeben zusammen genau einen begrenzten Batteriesollwert.
Pro physischer Batterie darf nur eine Stelle führen. Messgrundlage und
Aufteilung der berechneten Leistungsanteile sind im Test eindeutig zu belegen.
- SDL wird vor dem Eigenverbrauch auf die reservierte Leistung begrenzt.
- Gleichgerichtete Eigenverbrauchsleistung erhaelt nur die verbleibende
physische Leistung.
- Gegenlaeufige Auftraege werden bilanziell getrennt und physisch als
Nettosollwert verteilt.
- Fehlende oder veraltete Batterien werden aus der aktuellen Aggregation
entfernt und auf 0 W gesetzt.
- Bei Deaktivierung, ungueltiger Konfiguration oder ohne gueltige Batterie
werden alle physischen Sollleistungsvariablen auf 0 W gesetzt.
- Eine erkannte Integrationsluecke wird gemeldet und nicht still auf eine
kuerzere Dauer begrenzt.
- Physische Zielvariablen werden ausschliesslich mit `RequestAction`
geschrieben.
## Inbetriebnahme
1. Mess- und Sollleistungsvariablen aller physischen Batterien festlegen.
2. Batterieliste konfigurieren und Messwertalter kontrollieren.
3. SDL-Leistungsreserve und Reservezeit eintragen.
4. Das vorhandene EMS-Batteriemodul auf die EV-Proxyregister konfigurieren.
5. Die VGT-Schnittstelle auf die drei SDL-Variablen konfigurieren.
6. Zuerst mit deaktivierter virtueller Batterie alle Vorzeichen pruefen.
7. Danach Eigenverbrauch und SDL einzeln testen.
8. Erst abschliessend gleich- und gegenlaeufige Auftraege pruefen.
+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: `Energiediagramm`, `ShellyModul`,
Verfügbare Module: `CC100Hardware`, `Energiediagramm`, `ShellyModul`,
`Verbrauchskostenreport`.
Die Utils-Module werden jeweils eigenständig und ohne Abhängigkeit zu Enelix EMS getestet.
+73
View File
@@ -0,0 +1,73 @@
<?php
declare(strict_types=1);
namespace Belevo\EnelixUtils\Tests;
use PHPUnit\Framework\TestCase;
final class CC100HardwareModulstrukturTest extends TestCase
{
public function testModuleMetadataAndFormAreComplete(): void
{
$module = json_decode(
file_get_contents(__DIR__ . '/../CC100Hardware/module.json'),
true,
512,
JSON_THROW_ON_ERROR
);
$form = json_decode(
file_get_contents(__DIR__ . '/../CC100Hardware/form.json'),
true,
512,
JSON_THROW_ON_ERROR
);
self::assertSame('{D62EA550-68A5-48E2-BF31-1C721AA1BD4E}', $module['id']);
self::assertSame('CC100 Hardware', $module['name']);
self::assertSame('CC100', $module['prefix']);
self::assertNotEmpty($form['elements']);
self::assertNotEmpty($form['status']);
}
public function testModuleProvidesEveryCc100IoGroupAndConfigurablePolling(): void
{
$module = file_get_contents(__DIR__ . '/../CC100Hardware/module.php');
$form = file_get_contents(__DIR__ . '/../CC100Hardware/form.json');
self::assertNotFalse($module);
self::assertNotFalse($form);
foreach ([
'DigitalInputPath',
'DigitalOutputPath',
'AI1Path',
'AI2Path',
'AO1ValuePath',
'AO2ValuePath',
'PT1Path',
'PT2Path',
] as $property) {
self::assertStringContainsString($property, $module);
self::assertStringContainsString($property, $form);
}
self::assertStringContainsString('Abfrageintervall', $form);
self::assertStringContainsString("RegisterTimer(\n 'PollChannels'", $module);
}
public function testOutputsAreOnlyWrittenByExplicitActions(): void
{
$module = file_get_contents(__DIR__ . '/../CC100Hardware/module.php');
self::assertNotFalse($module);
$applyStart = strpos($module, 'public function ApplyChanges');
$actionStart = strpos($module, 'public function RequestAction');
self::assertNotFalse($applyStart);
self::assertNotFalse($actionStart);
self::assertStringNotContainsString(
'writeFile(',
substr($module, $applyStart, $actionStart - $applyStart)
);
self::assertStringNotContainsString('TryWriteFile', $module);
self::assertStringNotContainsString('usleep(', $module);
}
}
+91
View File
@@ -0,0 +1,91 @@
<?php
declare(strict_types=1);
namespace Belevo\EnelixUtils\Tests;
use Belevo\EnelixUtils\CC100\CC100ChannelModel;
use InvalidArgumentException;
use PHPUnit\Framework\TestCase;
require_once __DIR__ . '/../CC100Hardware/libs/CC100ChannelModel.php';
final class CC100HardwareTest extends TestCase
{
public function testDefaultSelectionContainsEveryOnboardChannelExactlyOnce(): void
{
$selection = CC100ChannelModel::defaultSelection();
self::assertCount(18, $selection);
self::assertCount(
18,
array_unique(array_map(
static fn (array $row): string => $row['Typ'] . $row['Kanal'],
$selection
))
);
self::assertSame([], array_filter(
$selection,
static fn (array $row): bool => $row['Aktiv']
));
}
public function testSelectionNormalizesNamesAndIntervals(): void
{
$selection = CC100ChannelModel::normalizeSelection(json_encode([
[
'Aktiv' => true,
'Typ' => 'di',
'Kanal' => 1,
'Anzeigename' => '',
'Abfrageintervall' => 250,
],
[
'Aktiv' => true,
'Typ' => 'AO',
'Kanal' => 2,
'Anzeigename' => 'Ventil',
'Abfrageintervall' => 5000,
],
], JSON_THROW_ON_ERROR));
self::assertSame('DI1', $selection[0]['key']);
self::assertSame('Digitaler Eingang 1', $selection[0]['name']);
self::assertSame(250, $selection[0]['pollInterval']);
self::assertSame('AO2', $selection[1]['key']);
self::assertSame('Ventil', $selection[1]['name']);
}
public function testDuplicateChannelIsRejected(): void
{
$this->expectException(InvalidArgumentException::class);
CC100ChannelModel::normalizeSelection(json_encode([
['Typ' => 'DI', 'Kanal' => 1, 'Abfrageintervall' => 500],
['Typ' => 'DI', 'Kanal' => 1, 'Abfrageintervall' => 500],
], JSON_THROW_ON_ERROR));
}
public function testCalibrationSupportsWagoAZeroOneHeaderAndBothDirections(): void
{
$calibration = CC100ChannelModel::parseCalibration(
"AI1 A01\n100 0 1100 10000\n200 0 1200 10000\n"
);
self::assertArrayHasKey('AO1', $calibration);
self::assertSame(5000.0, CC100ChannelModel::rawToEngineering(600, $calibration['AI1']));
self::assertSame(700, CC100ChannelModel::engineeringToRaw(5000, $calibration['AO1']));
}
public function testPt1000CurveAndBitOperationsAreStable(): void
{
self::assertSame(0.0, CC100ChannelModel::pt1000Temperature(1000.0));
self::assertEqualsWithDelta(100.0, CC100ChannelModel::pt1000Temperature(1385.05), 0.1);
self::assertTrue(CC100ChannelModel::bitState(5, 1));
self::assertFalse(CC100ChannelModel::bitState(5, 2));
self::assertTrue(CC100ChannelModel::bitState(5, 3));
self::assertSame(7, CC100ChannelModel::withBitState(5, 2, true));
self::assertSame(4, CC100ChannelModel::withBitState(5, 1, false));
}
}
+3 -3
View File
@@ -13,10 +13,10 @@ final class DokumentationsstrukturTest extends TestCase
{
return [
'Verbrauchskostenreport' => ['Verbrauchskostenreport', 'Status: Implementiert'],
'Virtuelle Batterie' => ['Virtuelle-Batterie', 'Status: Diskussionsentwurf'],
'CC100 Hardware' => ['CC100-Hardware', 'Status: Diskussionsentwurf'],
'Virtuelle Batterie' => ['Virtuelle-Batterie', 'Status: Implementiert'],
'CC100 Hardware' => ['CC100-Hardware', 'Status: Implementiert'],
'Energiediagramm' => ['Energiediagramm', 'Status: Implementiert'],
'VGT-Schnittstelle' => ['VGT-Schnittstelle', 'Status: Diskussionsentwurf'],
'VGT-Schnittstelle' => ['VGT-Schnittstelle', 'Status: Implementiert'],
'Shelly-Modul' => ['Shelly-Modul', 'Status: Implementiert'],
];
}
+91
View File
@@ -0,0 +1,91 @@
<?php
declare(strict_types=1);
namespace Belevo\EnelixUtils\Tests;
use PHPUnit\Framework\TestCase;
final class NeueModuleStrukturTest extends TestCase
{
/** @dataProvider module */
public function testMetadatenUndFormulareSindGueltig(
string $ordner,
string $name,
string $prefix
): void {
$metadata = json_decode(
(string) file_get_contents(
__DIR__ . '/../' . $ordner . '/module.json'
),
true,
512,
JSON_THROW_ON_ERROR
);
self::assertSame($name, $metadata['name']);
self::assertSame($prefix, $metadata['prefix']);
self::assertSame(3, $metadata['type']);
self::assertMatchesRegularExpression(
'/^\{[0-9A-F-]{36}\}$/',
$metadata['id']
);
$formular = json_decode(
(string) file_get_contents(
__DIR__ . '/../' . $ordner . '/form.json'
),
true,
512,
JSON_THROW_ON_ERROR
);
self::assertNotEmpty($formular['elements']);
}
/** @return array<string, array{string, string, string}> */
public function module(): array
{
return [
'Virtuelle Batterie' => [
'VirtuelleBatterie',
'Virtuelle Batterie',
'ENLVB',
],
'VGT-Schnittstelle' => [
'VGTSchnittstelle',
'VGTSchnittstelle',
'ENLVGT',
],
];
}
public function testVirtuelleBatterieStelltEVProxyregisterBereit(): void
{
$modul = (string) file_get_contents(
__DIR__ . '/../VirtuelleBatterie/module.php'
);
foreach ([
"'EVManagement'",
"'EVModus'",
"'EVLadeleistung'",
"'EVEntladeleistung'",
"'SDLSollleistung'",
'VirtuelleBatterieRegler::kombiniere',
] as $bestandteil) {
self::assertStringContainsString($bestandteil, $modul);
}
}
public function testPhysischeSollwerteWerdenNurPerActionGeschrieben(): void
{
$modul = (string) file_get_contents(
__DIR__ . '/../VirtuelleBatterie/module.php'
);
$anfang = strpos($modul, 'private function schreibeVerteilung');
$ende = strpos($modul, 'private function sichererZustand');
self::assertNotFalse($anfang);
self::assertNotFalse($ende);
$funktion = substr($modul, $anfang, $ende - $anfang);
self::assertStringContainsString('RequestAction(', $funktion);
self::assertStringNotContainsString('SetValue($zielID', $funktion);
}
}
+18
View File
@@ -3,12 +3,30 @@
declare(strict_types=1);
return [
'CC100Hardware' => [
'file' => __DIR__ . '/modules/CC100Hardware.php',
'paths' => [
'CC100Hardware/*',
],
],
'Energiediagramm' => [
'file' => __DIR__ . '/modules/Energiediagramm.php',
'paths' => [
'Energiediagramm/*',
],
],
'VGTSchnittstelle' => [
'file' => __DIR__ . '/modules/VGTSchnittstelle.php',
'paths' => [
'VGTSchnittstelle/*',
],
],
'VirtuelleBatterie' => [
'file' => __DIR__ . '/modules/VirtuelleBatterie.php',
'paths' => [
'VirtuelleBatterie/*',
],
],
'ShellyModul' => [
'file' => __DIR__ . '/modules/ShellyModul.php',
'paths' => [
+118
View File
@@ -0,0 +1,118 @@
<?php
declare(strict_types=1);
use Belevo\EnelixUtils\SymconTest\TestContext;
return static function (TestContext $test): void {
$temporaryFiles = [];
$createFile = static function (string $content) use (&$temporaryFiles): string {
$path = tempnam(sys_get_temp_dir(), 'enelix_cc100_');
if ($path === false || file_put_contents($path, $content) === false) {
throw new RuntimeException('Temporäre CC100-Testdatei konnte nicht erstellt werden.');
}
$temporaryFiles[] = $path;
return $path;
};
$digitalInput = $createFile('5');
$digitalOutput = $createFile('0');
$calibration = $createFile(
"PT1 PT2 AI1 AI2 A01 A02\n"
. "1000 1000 2000 1385.05\n"
. "1000 1000 2000 1385.05\n"
. "100 0 1100 10000\n"
. "100 0 1100 10000\n"
. "200 0 1200 10000\n"
. "200 0 1200 10000\n"
);
$ai1 = $createFile('600');
$ao1 = $createFile('700');
$ao1Power = $createFile('1');
$pt1 = $createFile('1000');
$channels = json_encode([
[
'Aktiv' => true,
'Typ' => 'DI',
'Kanal' => 1,
'Anzeigename' => 'Test Eingang',
'Abfrageintervall' => 1000,
],
[
'Aktiv' => true,
'Typ' => 'DO',
'Kanal' => 1,
'Anzeigename' => 'Test Ausgang',
'Abfrageintervall' => 1000,
],
[
'Aktiv' => true,
'Typ' => 'AI',
'Kanal' => 1,
'Anzeigename' => 'Test Spannung Eingang',
'Abfrageintervall' => 1000,
],
[
'Aktiv' => true,
'Typ' => 'AO',
'Kanal' => 1,
'Anzeigename' => 'Test Spannung Ausgang',
'Abfrageintervall' => 1000,
],
[
'Aktiv' => true,
'Typ' => 'PT',
'Kanal' => 1,
'Anzeigename' => 'Test Temperatur',
'Abfrageintervall' => 1000,
],
], JSON_THROW_ON_ERROR);
$moduleId = $test->createInstance(
'{D62EA550-68A5-48E2-BF31-1C721AA1BD4E}',
'CC100 Hardware',
[
'Kanalauswahl' => $channels,
'DigitalInputPath' => $digitalInput,
'DigitalOutputPath' => $digitalOutput,
'CalibrationPath' => $calibration,
'AI1Path' => $ai1,
'AO1ValuePath' => $ao1,
'AO1PowerPath' => $ao1Power,
'PT1Path' => $pt1,
]
);
$test->runCase('Nur ausgewaehlte Kanaele werden angelegt und gelesen', static function (
TestContext $test
) use ($moduleId): void {
$test->assertInstanceStatus($moduleId);
$test->assertSame(true, GetValue($test->objectByIdent('DI1', $moduleId)));
$test->assertSame(false, @IPS_GetObjectIDByIdent('DI2', $moduleId));
$test->assertEquals(5.0, (float) GetValue($test->objectByIdent('AI1', $moduleId)), 0.001);
$test->assertEquals(0.0, (float) GetValue($test->objectByIdent('PT1', $moduleId)), 0.1);
});
$test->runCase('Digitaler Ausgang erhaelt die anderen Bits', static function (
TestContext $test
) use ($moduleId, $digitalOutput): void {
file_put_contents($digitalOutput, '4');
IPS_RequestAction($moduleId, 'DO1', true);
$test->assertSame('5', trim((string) file_get_contents($digitalOutput)));
$test->assertSame(true, GetValue($test->objectByIdent('DO1', $moduleId)));
});
$test->runCase('Analoger Ausgang wird invers kalibriert geschrieben', static function (
TestContext $test
) use ($moduleId, $ao1, $ao1Power): void {
IPS_RequestAction($moduleId, 'AO1', 2.5);
$test->assertSame('0', trim((string) file_get_contents($ao1Power)));
$test->assertSame('450', trim((string) file_get_contents($ao1)));
$test->assertEquals(2.5, (float) GetValue($test->objectByIdent('AO1', $moduleId)), 0.001);
});
foreach ($temporaryFiles as $path) {
@unlink($path);
}
};
+48
View File
@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
use Belevo\EnelixUtils\SymconTest\TestContext;
return static function (TestContext $test): void {
$instancesBefore = array_flip(IPS_GetInstanceList());
$mqttId = $test->createInstance(
'{C6D2AEB3-6E1F-4B2E-8E69-3A1A00246850}',
'MQTT-Testserver'
);
$ioId = (int) IPS_GetInstance($mqttId)['ConnectionID'];
if ($ioId > 0 && !isset($instancesBefore[$ioId])) {
IPS_SetParent($ioId, $test->rootId());
}
$moduleId = $test->createInstance(
'{4BA1A557-7970-4FB9-93B8-1F6ADA0A5A94}',
'VGT-Schnittstelle'
);
if ((int) IPS_GetInstance($moduleId)['ConnectionID'] !== $mqttId) {
IPS_DisconnectInstance($moduleId);
IPS_ConnectInstance($moduleId, $mqttId);
IPS_ApplyChanges($moduleId);
}
$test->runCase('MQTT-Elterninstanz wird verwendet', static function (
TestContext $test
) use ($moduleId, $mqttId): void {
$test->assertSame(
$mqttId,
(int) IPS_GetInstance($moduleId)['ConnectionID']
);
});
$test->runCase('Konfigurationsformular ist gueltiges JSON', static function (
TestContext $test
) use ($moduleId): void {
$form = json_decode(
IPS_GetConfigurationForm($moduleId),
true,
512,
JSON_THROW_ON_ERROR
);
$test->assertTrue(is_array($form));
});
};
@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
use Belevo\EnelixUtils\SymconTest\TestContext;
return static function (TestContext $test): void {
$moduleId = $test->createInstance(
'{5B2A199B-2226-411F-B83B-FC1CBA58496B}',
'Virtuelle Batterie'
);
$test->runCase('Konfigurationsformular ist gueltiges JSON', static function (
TestContext $test
) use ($moduleId): void {
$form = json_decode(
IPS_GetConfigurationForm($moduleId),
true,
512,
JSON_THROW_ON_ERROR
);
$test->assertTrue(is_array($form));
});
$test->runCase('Sichere Startkonfiguration ist inaktiv', static function (
TestContext $test
) use ($moduleId): void {
$aktivId = IPS_GetObjectIDByIdent('Aktiv', $moduleId);
$test->assertSame(false, GetValue($aktivId));
});
};
+8 -1
View File
@@ -58,7 +58,14 @@ final class SymconTestContractTest extends TestCase
public function testAffectedModuleResolverSelectsAllModulesForFrameworkChanges(): void
{
self::assertSame(
['Energiediagramm', 'ShellyModul', 'Verbrauchskostenreport'],
[
'CC100Hardware',
'Energiediagramm',
'ShellyModul',
'VGTSchnittstelle',
'Verbrauchskostenreport',
'VirtuelleBatterie',
],
$this->resolveAffectedModules(['tests/Symcon/TestContext.php'])
);
}
+77
View File
@@ -0,0 +1,77 @@
<?php
declare(strict_types=1);
namespace Belevo\EnelixUtils\Tests;
use Belevo\EnelixUtils\VGTRegler;
use PHPUnit\Framework\TestCase;
require_once __DIR__ . '/../VGTSchnittstelle/libs/VGTRegler.php';
final class VGTReglerTest extends TestCase
{
public function testPVActivateUndStopBleibenKompatibel(): void
{
self::assertSame(
4000.0,
VGTRegler::zielwert(0, 'activate', 4000.0, 10000.0, 0.0, 0.0, 0.0, 0.0, 0.0, 100.0)
);
self::assertSame(
10000.0,
VGTRegler::zielwert(0, 'stop', 4000.0, 10000.0, 0.0, 0.0, 0.0, 0.0, 0.0, 100.0)
);
}
public function testBatterieActivateInvertiertDenSetpointWieEnelixEins(): void
{
self::assertSame(
-3500.0,
VGTRegler::zielwert(1, 'activate', 3500.0, 10000.0, 50.0, 50.0, 2500.0, 2500.0, 10.0, 90.0)
);
}
public function testRestoreRegeltAufZielLadezustand(): void
{
self::assertSame(
2500.0,
VGTRegler::zielwert(1, 'restore', 0.0, 10000.0, 40.0, 50.0, 2500.0, 3000.0, 10.0, 90.0)
);
self::assertSame(
-3000.0,
VGTRegler::zielwert(1, 'restore', 0.0, 10000.0, 60.0, 50.0, 2500.0, 3000.0, 10.0, 90.0)
);
}
public function testLadezustandsgrenzenStoppenFalscheRichtung(): void
{
self::assertSame(
0.0,
VGTRegler::zielwert(1, 'activate', 3000.0, 10000.0, 10.0, 50.0, 2500.0, 2500.0, 10.0, 90.0)
);
}
public function testFesteMQTTSchnittstelleBleibtImModulErhalten(): void
{
$modul = file_get_contents(
__DIR__ . '/../VGTSchnittstelle/module.php'
);
self::assertNotFalse($modul);
foreach ([
'feedback-request/',
'feedback-response/',
'remote-control-request/',
'remote-control-response/',
"'power_setpoint'",
"'strategy'",
"'power_production'",
"'is_ready'",
"'is_running'",
"'state_of_charge'",
"'min_soc'",
"'max_soc'",
] as $bestandteil) {
self::assertStringContainsString($bestandteil, $modul);
}
}
}
+111
View File
@@ -0,0 +1,111 @@
<?php
declare(strict_types=1);
namespace Belevo\EnelixUtils\Tests;
use Belevo\EnelixUtils\VirtuelleBatterieRegler;
use PHPUnit\Framework\TestCase;
require_once __DIR__
. '/../VirtuelleBatterie/libs/VirtuelleBatterieRegler.php';
final class VirtuelleBatterieReglerTest extends TestCase
{
public function testAggregatIstKapazitaetsgewichtet(): void
{
$aggregat = VirtuelleBatterieRegler::aggregiere([
$this->batterie(0, 10.0, 20.0, 5000.0, 5000.0),
$this->batterie(1, 30.0, 80.0, 10000.0, 8000.0),
], 3000.0, 2000.0, 0.5);
self::assertSame(40.0, $aggregat['Kapazitaet_kWh']);
self::assertSame(65.0, $aggregat['Ladezustand_Prozent']);
self::assertSame(15000.0, $aggregat['MaxLaden_W']);
self::assertSame(13000.0, $aggregat['MaxEntladen_W']);
self::assertSame(2.5, $aggregat['SDLKapazitaet_kWh']);
self::assertSame(37.5, $aggregat['EigenverbrauchKapazitaet_kWh']);
}
public function testSDLHatBeiGleicherRichtungVorrang(): void
{
$aggregat = VirtuelleBatterieRegler::aggregiere([
$this->batterie(0, 20.0, 50.0, 10000.0, 10000.0),
], 6000.0, 6000.0, 0.5);
$auftrag = VirtuelleBatterieRegler::kombiniere(
8000.0,
6000.0,
$aggregat
);
self::assertSame(6000.0, $auftrag['SDL_W']);
self::assertSame(4000.0, $auftrag['Eigenverbrauch_W']);
self::assertSame(10000.0, $auftrag['Gesamt_W']);
}
public function testGegenlaeufigeAuftraegeWerdenNettoVerteilt(): void
{
$aggregat = VirtuelleBatterieRegler::aggregiere([
$this->batterie(0, 20.0, 50.0, 10000.0, 10000.0),
], 6000.0, 6000.0, 0.5);
$auftrag = VirtuelleBatterieRegler::kombiniere(
4000.0,
-3000.0,
$aggregat
);
self::assertSame(1000.0, $auftrag['Gesamt_W']);
self::assertSame(-3000.0, $auftrag['SDL_W']);
self::assertSame(4000.0, $auftrag['Eigenverbrauch_W']);
}
public function testLadenBeginntBeiNiedrigstemLadezustand(): void
{
$verteilung = VirtuelleBatterieRegler::verteile(7000.0, [
$this->batterie(0, 10.0, 80.0, 5000.0, 5000.0),
$this->batterie(1, 10.0, 20.0, 5000.0, 5000.0),
]);
self::assertSame(2000.0, $verteilung[0]);
self::assertSame(5000.0, $verteilung[1]);
}
public function testIstleistungOrdnetSDLZuerstZu(): void
{
self::assertSame(
['Eigenverbrauch_W' => 4000.0, 'SDL_W' => -3000.0],
VirtuelleBatterieRegler::ordneIstleistungZu(1000.0, -3000.0)
);
}
/**
* @return array{
* Index:int,
* Name:string,
* Kapazitaet_kWh:float,
* MaxLaden_W:float,
* MaxEntladen_W:float,
* Ladezustand_Prozent:float,
* Istleistung_W:float
* }
*/
private function batterie(
int $index,
float $kapazitaet,
float $ladezustand,
float $maxLaden,
float $maxEntladen
): array {
return [
'Index' => $index,
'Name' => 'Batterie ' . $index,
'Kapazitaet_kWh' => $kapazitaet,
'MaxLaden_W' => $maxLaden,
'MaxEntladen_W' => $maxEntladen,
'Ladezustand_Prozent' => $ladezustand,
'Istleistung_W' => 0.0,
];
}
}