8 Commits
Author SHA1 Message Date
dh 5b6125d086 fix(energy-pie): represent indeterminate shares and preserve prepared styling
Tests / test (push) Successful in 50s
2026-10-06 17:40:42 +00:00
dh c5f924bd19 fix(metadata): set Enelix Utils author to Enelix
Tests / test (push) Successful in 50s
2026-10-06 13:38:01 +00:00
dh 82860c0eb9 fix(energiediagramm): Visualisierungsthema übernehmen
Tests / test (push) Successful in 1m51s
2026-10-01 15:34:23 +00:00
dh 59eec738ab chore(release): erste Beta vorbereiten
Tests / test (push) Successful in 51s
2026-09-29 06:25:29 +00:00
dh 40c6a17dd1 fix(energiediagramm): Symcon-8-Darstellung korrigieren 2026-09-29 06:24:29 +00:00
dh 1f945be8f1 feat: Verbrauchskostenreport lizenzieren
Tests / test (push) Successful in 45s
2026-09-27 12:33:36 +00:00
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
50 changed files with 4584 additions and 178 deletions
+8
View File
@@ -21,6 +21,14 @@ jobs:
- name: Abhaengigkeiten installieren - name: Abhaengigkeiten installieren
run: composer install --no-interaction --no-progress --prefer-dist run: composer install --no-interaction --no-progress --prefer-dist
- name: Composer-Metadaten pruefen
run: composer validate --strict
- name: Abhaengigkeiten auf Sicherheitsmeldungen pruefen
run: |
composer audit --no-interaction
composer audit --working-dir=Verbrauchskostenreport/libs --locked --no-interaction
- name: Unit-Tests und Syntaxpruefung ausfuehren - name: Unit-Tests und Syntaxpruefung ausfuehren
run: composer check run: composer check
+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);
}
}
+34
View File
@@ -0,0 +1,34 @@
# Changelog
Alle wesentlichen Aenderungen an Enelix Utils werden in dieser Datei dokumentiert.
## Unreleased
- Energiediagramm zeigt wieder den urspruenglichen dunklen Energy-Pie-Hintergrund
mit blauen und violetten Farbverlaeufen, auch bei hellem Visualisierungsthema.
Bedienelemente bleiben lesbar; Konfiguration und Berechnungen sind unveraendert.
## 0.1 Build 1 - Erste Beta - 2026-09-29
### Enthalten
- Verbrauchskostenreport mit PDF-Ausgabe und Schweizer QR-Zahlteil nach SIX 2.3.
- Verbrauchskostenreport mit Grundlizenz sowie Strom- und Nebenzaehlerkontingenten.
- Virtuelle Batterie und VGT-Schnittstelle.
- CC100 Hardware und generisches Shelly Modul.
- Energiediagramm fuer die IP-Symcon-Kachelvisualisierung.
### Beta-Aenderungen
- Energiediagramm uebernimmt Kachel- und Textfarbe der Visualisierung; Zeitraum-
und Datumsauswahl bleiben in hellen und dunklen Themen lesbar.
- Dezimale IP-Symcon-Farbwerte werden im Energiediagramm korrekt verarbeitet.
- Kachelabstand fuer den Symcon-Kopf und versionsabhaengiger Vollbildmodus.
- Vollstaendige PHP-, Struktur- und Symcon-Modultests fuer alle Module.
- Composer-Metadaten- und Abhaengigkeitspruefung in der CI.
### Hinweise
- Diese Version ist fuer kontrollierte Anlagen- und Feldtests bestimmt.
- Vor produktivem Einsatz sind Sicherung und anlagenspezifische Abnahme erforderlich.
- Korrekturen werden vorwaerts ueber `develop` und anschliessend `beta` verteilt.
+19 -5
View File
@@ -24,14 +24,28 @@ zusaetzlich ein frei waehlbarer inklusiver Datumsbereich und die Navigation
durch abgeschlossene Zeitraeume zur Verfuegung. durch abgeschlossene Zeitraeume zur Verfuegung.
Die Farben fuer Eigenverbrauchsquote und Autarkiegrad werden mit den beiden Die Farben fuer Eigenverbrauchsquote und Autarkiegrad werden mit den beiden
Farbauswahlen in der Instanzkonfiguration festgelegt. Farbauswahlen in der Instanzkonfiguration festgelegt. Das Modul verarbeitet
sowohl aktuelle Hex-Farbwerte als auch die von IP-Symcon 8.0 gespeicherten
dezimalen Farbwerte.
## Darstellung ## Darstellung
Die Kachel und ihre vergroesserte Ansicht erhalten beim Aufbau sofort einen Der Inhalt beginnt unterhalb des von Symcon eingeblendeten Kachelkopfs, damit
vollstaendigen Datenstand. Beim Oeffnen und beim erneuten Sichtbarwerden wird Zeitraum und Datum vollstaendig bedienbar bleiben. Der urspruengliche Energy-Pie-
die Darstellung aktualisiert. Das Diagrammraster wechselt bei geringer Breite Hintergrund mit dunkler Grundfarbe und blauen/violetten Farbverlaeufen bleibt
automatisch von zwei Spalten auf eine Spalte. auch in einem hellen Visualisierungsthema sichtbar. Text, Auswahlfelder und
native Datumselemente verwenden innerhalb des Moduls dazu passende helle
Schrift und dunkle Bedienflaechen. Die konfigurierten Diagrammfarben bleiben
unveraendert; eine Konfigurationsmigration ist nicht erforderlich. Die
Hintergrundebene blockiert weder Klicks noch Scrollen. Beim Oeffnen und beim
erneuten Sichtbarwerden wird die Darstellung aktualisiert. Das
Diagrammraster wechselt bei geringer Breite automatisch von zwei Spalten auf
eine Spalte.
IP-Symcon 8.x unterstuetzt HTML-SDK-Darstellungen nur in der normalen Kachel.
Der vom Client angebotene Vergroesserungsdialog kann deshalb leer bleiben.
Ab IP-Symcon 9.0 aktiviert das Modul automatisch den zusaetzlichen
HTML-SDK-Vollbildmodus.
## Berechnung ## Berechnung
+18
View File
@@ -0,0 +1,18 @@
# Nicht eindeutig bestimmbare Energieanteile
Die optionale Property `EnergieanteileBerechenbar` ist standardmaessig true und
erhaelt das bisherige Verhalten. Setzt ein aufrufender Manager sie auf false,
bleiben gemessene Summen erhalten. Eigenverbrauch und zugehoerige Anteile werden
als null geliefert und als nicht bestimmbar dargestellt, niemals als 0 Prozent.
Anwendungsfall: Netzbezug enthaelt SDL, die Hauslast wurde um SDL bereinigt.
Ohne eindeutige Zuordnung laesst sich der solare Anteil nicht exakt bestimmen.
Die Policy gehoert in EMS; Utils implementiert nur die generische Darstellung.
Individuelle Quellen, Farben und Energiezaehler werden nicht veraendert. Die
bereits vorbereitete Wiederherstellung des dunklen Pie-Hintergrunds bleibt
erhalten. Utils vor einem Manager mit dieser neuen Property aktualisieren.
Validierung: 58 PHPUnit-Tests und vier zusaetzliche JavaScript-Visualisierungstests
erfolgreich; unbekannte, alte numerische und spaeter wieder verfuegbare Anteile
sind abgedeckt.
+9
View File
@@ -32,6 +32,15 @@
"maximum": 1000000, "maximum": 1000000,
"digits": 6 "digits": 6
}, },
{
"type": "CheckBox",
"name": "EnergieanteileBerechenbar",
"caption": "Eigenverbrauch und Autarkie aus gemeinsamer Energiebilanz berechnen"
},
{
"type": "Label",
"caption": "Bei abweichenden Messgrenzen oder nicht zuordenbaren Speicherfluessen ausschalten. Energiesummen bleiben sichtbar; Anteile werden als nicht bestimmbar gekennzeichnet."
},
{ {
"type": "Select", "type": "Select",
"name": "Standardzeitraum", "name": "Standardzeitraum",
+23 -12
View File
@@ -82,16 +82,17 @@ final class EnergyChartCalculator
* Einspeisung: float, * Einspeisung: float,
* Netzbezug: float, * Netzbezug: float,
* Hausverbrauch: float, * Hausverbrauch: float,
* Eigenverbrauch: float, * Eigenverbrauch: float|null,
* Eigenverbrauchsquote: float, * Eigenverbrauchsquote: float|null,
* Autarkiegrad: float * Autarkiegrad: float|null
* } * }
*/ */
public static function metrics( public static function metrics(
float $production, float $production,
float $feedIn, float $feedIn,
float $grid, float $grid,
?float $consumption = null ?float $consumption = null,
bool $sharesAvailable = true
): array { ): array {
$production = max(0.0, $production); $production = max(0.0, $production);
$feedIn = max(0.0, $feedIn); $feedIn = max(0.0, $feedIn);
@@ -106,21 +107,31 @@ final class EnergyChartCalculator
'Einspeisung' => $feedIn, 'Einspeisung' => $feedIn,
'Netzbezug' => $grid, 'Netzbezug' => $grid,
'Hausverbrauch' => $house, 'Hausverbrauch' => $house,
'Eigenverbrauch' => $selfConsumption, 'Eigenverbrauch' => $sharesAvailable ? $selfConsumption : null,
'Eigenverbrauchsquote' => $production > 0.0 'Eigenverbrauchsquote' => !$sharesAvailable ? null : ($production > 0.0
? min(100.0, ($selfConsumption / $production) * 100.0) ? min(100.0, ($selfConsumption / $production) * 100.0)
: 0.0, : 0.0),
'Autarkiegrad' => $house > 0.0 'Autarkiegrad' => !$sharesAvailable ? null : ($house > 0.0
? min(100.0, ($selfConsumption / $house) * 100.0) ? min(100.0, ($selfConsumption / $house) * 100.0)
: 0.0, : 0.0),
]; ];
} }
public static function normalizeColor(string $color, string $fallback): string public static function normalizeColor(string $color, string $fallback): string
{ {
return preg_match('/^#[0-9A-Fa-f]{6}$/', $color) === 1 $color = trim($color);
? strtoupper($color) if (preg_match('/^#[0-9A-Fa-f]{6}$/', $color) === 1) {
: strtoupper($fallback); return strtoupper($color);
}
if (preg_match('/^[0-9]+$/', $color) === 1) {
$legacyColor = (int) $color;
if ($legacyColor >= 0 && $legacyColor <= 0xFFFFFF) {
return sprintf('#%06X', $legacyColor);
}
}
return strtoupper($fallback);
} }
private static function date(string $date): DateTimeImmutable private static function date(string $date): DateTimeImmutable
+76 -17
View File
@@ -63,19 +63,41 @@
html, html,
body { body {
min-width: 0; min-width: 0;
height: 100%;
min-height: 100%; min-height: 100%;
margin: 0; margin: 0;
color: var(--content-color, inherit);
background-color: var(--card-color, transparent);
} }
#energy-app { #energy-app {
--card-color: #121216;
--content-color: #f5f5f7;
position: relative;
isolation: isolate;
color-scheme: dark;
width: 100%; width: 100%;
min-height: 100%; min-height: 100%;
box-sizing: border-box; box-sizing: border-box;
padding: 14px; padding: 58px 14px 14px;
overflow: auto; overflow: auto;
font-family: system-ui, -apple-system, "Segoe UI", sans-serif; font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
color: inherit; color: var(--content-color, inherit);
background: transparent; background-color: var(--card-color, transparent);
}
/* Keep the original Energy Pie colors behind the scrollable controls. */
#energy-app::before {
content: "";
position: absolute;
inset: 0;
z-index: -1;
background:
radial-gradient(520px 420px at 18% 18%, rgba(99, 179, 255, 0.42), transparent 60%),
radial-gradient(560px 460px at 82% 28%, rgba(168, 85, 247, 0.38), transparent 62%),
radial-gradient(620px 520px at 55% 85%, rgba(99, 179, 255, 0.26), transparent 64%);
filter: blur(18px);
pointer-events: none;
} }
.toolbar, .toolbar,
@@ -104,11 +126,16 @@
border: 1px solid rgba(127, 127, 127, 0.45); border: 1px solid rgba(127, 127, 127, 0.45);
border-radius: 6px; border-radius: 6px;
padding: 7px 10px; padding: 7px 10px;
color: inherit; color: var(--content-color, inherit);
background: rgba(127, 127, 127, 0.08); background-color: var(--card-color, transparent);
font: inherit; font: inherit;
} }
select option {
color: var(--content-color, CanvasText);
background-color: var(--card-color, Canvas);
}
button { button {
cursor: pointer; cursor: pointer;
font-weight: 650; font-weight: 650;
@@ -221,6 +248,11 @@
dominant-baseline: middle; dominant-baseline: middle;
} }
.donut-label.unavailable {
font-size: 14px;
font-weight: 600;
}
.error { .error {
min-height: 18px; min-height: 18px;
margin-top: 8px; margin-top: 8px;
@@ -234,7 +266,7 @@
@media (max-width: 560px) { @media (max-width: 560px) {
#energy-app { #energy-app {
padding: 10px; padding: 58px 10px 10px;
} }
.toolbar, .toolbar,
@@ -277,6 +309,23 @@
const selfConsumptionChart = document.getElementById('self-consumption-chart'); const selfConsumptionChart = document.getElementById('self-consumption-chart');
const autarkyChart = document.getElementById('autarky-chart'); const autarkyChart = document.getElementById('autarky-chart');
function syncColorScheme() {
const cardColor = getComputedStyle(document.documentElement)
.getPropertyValue('--card-color')
.trim();
const match = cardColor.match(/^#?([0-9a-f]{6})$/i);
if (!match) {
return;
}
const value = Number.parseInt(match[1], 16);
const red = (value >> 16) & 255;
const green = (value >> 8) & 255;
const blue = value & 255;
const luminance = ((0.2126 * red) + (0.7152 * green) + (0.0722 * blue)) / 255;
document.documentElement.style.colorScheme = luminance < 0.5 ? 'dark' : 'light';
}
function send(action, value) { function send(action, value) {
if (typeof requestAction !== 'function') { if (typeof requestAction !== 'function') {
return; return;
@@ -342,6 +391,8 @@
} }
function donut(target, percent, color) { function donut(target, percent, color) {
const available = percent !== null && percent !== undefined
&& Number.isFinite(Number(percent));
const value = Math.max(0, Math.min(100, asNumber(percent))); const value = Math.max(0, Math.min(100, asNumber(percent)));
const radius = 58; const radius = 58;
const circumference = 2 * Math.PI * radius; const circumference = 2 * Math.PI * radius;
@@ -349,15 +400,17 @@
target.innerHTML = [ target.innerHTML = [
'<svg viewBox="0 0 176 176" role="img" aria-label="' '<svg viewBox="0 0 176 176" role="img" aria-label="'
+ value.toFixed(1) + ' Prozent">', + (available ? value.toFixed(1) + ' Prozent' : 'Nicht bestimmbar') + '">',
'<circle class="donut-track" cx="88" cy="88" r="' + radius '<circle class="donut-track" cx="88" cy="88" r="' + radius
+ '" fill="none" stroke-width="18"></circle>', + '" fill="none" stroke-width="18"></circle>',
'<circle class="donut-value" cx="88" cy="88" r="' + radius available ? '<circle class="donut-value" cx="88" cy="88" r="' + radius
+ '" fill="none" stroke="' + color + '" stroke-width="18"' + '" fill="none" stroke="' + color + '" stroke-width="18"'
+ ' stroke-linecap="round" transform="rotate(-90 88 88)"' + ' stroke-linecap="round" transform="rotate(-90 88 88)"'
+ ' stroke-dasharray="' + filled + ' ' + circumference + '"></circle>', + ' stroke-dasharray="' + filled + ' ' + circumference + '"></circle>' : '',
'<text class="donut-label" x="88" y="91">' available ? '<text class="donut-label" x="88" y="91">'
+ value.toFixed(1) + '%</text>', + value.toFixed(1) + '%</text>'
: '<text class="donut-label unavailable" x="88" y="80">'
+ '<tspan x="88">Nicht</tspan><tspan x="88" dy="20">bestimmbar</tspan></text>',
'</svg>' '</svg>'
].join(''); ].join('');
} }
@@ -372,6 +425,7 @@
return; return;
} }
syncColorScheme();
range.value = data.range || 'day'; range.value = data.range || 'day';
date.value = data.date || ''; date.value = data.date || '';
customStart.value = data.customStart || ''; customStart.value = data.customStart || '';
@@ -396,19 +450,24 @@
const colors = data.colors || {}; const colors = data.colors || {};
donut( donut(
selfConsumptionChart, selfConsumptionChart,
values.Eigenverbrauchsquote, data.sharesAvailable === false ? null : values.Eigenverbrauchsquote,
colors.selfConsumption || '#2F80ED' colors.selfConsumption || '#2F80ED'
); );
donut( donut(
autarkyChart, autarkyChart,
values.Autarkiegrad, data.sharesAvailable === false ? null : values.Autarkiegrad,
colors.autarky || '#22A06B' colors.autarky || '#22A06B'
); );
message.hidden = data.hasData !== false; const hints = [];
message.textContent = data.hasData === false if (data.hasData === false) {
? (data.noDataHint || 'Keine Archivwerte verfügbar.') hints.push(data.noDataHint || 'Keine Archivwerte verfügbar.');
: ''; }
if (data.sharesAvailable === false) {
hints.push(data.attributionHint || 'Eigenverbrauch und Autarkie sind nicht eindeutig bestimmbar.');
}
message.hidden = hints.length === 0;
message.textContent = hints.join(' ');
} }
window.handleMessage = function (data) { window.handleMessage = function (data) {
+12 -3
View File
@@ -25,6 +25,7 @@ class Energiediagramm extends IPSModule
$this->RegisterPropertyInteger('NetzbezugZaehlerID', 0); $this->RegisterPropertyInteger('NetzbezugZaehlerID', 0);
$this->RegisterPropertyInteger('VerbrauchZaehlerID', 0); $this->RegisterPropertyInteger('VerbrauchZaehlerID', 0);
$this->RegisterPropertyFloat('Zaehlerfaktor', 1.0); $this->RegisterPropertyFloat('Zaehlerfaktor', 1.0);
$this->RegisterPropertyBoolean('EnergieanteileBerechenbar', true);
$this->RegisterPropertyString('Standardzeitraum', 'day'); $this->RegisterPropertyString('Standardzeitraum', 'day');
$this->RegisterPropertyInteger('Aktualisierungsintervall', 60); $this->RegisterPropertyInteger('Aktualisierungsintervall', 60);
$this->RegisterPropertyString('FarbeEigenverbrauch', self::COLOR_SELF_CONSUMPTION); $this->RegisterPropertyString('FarbeEigenverbrauch', self::COLOR_SELF_CONSUMPTION);
@@ -37,7 +38,10 @@ class Energiediagramm extends IPSModule
$this->RegisterAttributeString(self::ATTR_CUSTOM_START, $today); $this->RegisterAttributeString(self::ATTR_CUSTOM_START, $today);
$this->RegisterAttributeString(self::ATTR_CUSTOM_END, $today); $this->RegisterAttributeString(self::ATTR_CUSTOM_END, $today);
$this->SetVisualizationType(1); $visualizationType = version_compare(IPS_GetKernelVersion(), '9.0', '>=')
? 2
: 1;
$this->SetVisualizationType($visualizationType);
$this->RegisterTimer( $this->RegisterTimer(
'Refresh', 'Refresh',
0, 0,
@@ -250,6 +254,7 @@ class Energiediagramm extends IPSModule
$customStart, $customStart,
$customEnd $customEnd
); );
$sharesAvailable = $this->ReadPropertyBoolean('EnergieanteileBerechenbar');
$payload = [ $payload = [
'range' => $range, 'range' => $range,
@@ -260,6 +265,9 @@ class Energiediagramm extends IPSModule
'tEnd' => $end, 'tEnd' => $end,
'hasData' => false, 'hasData' => false,
'noDataHint' => 'Energiezähler konfigurieren.', 'noDataHint' => 'Energiezähler konfigurieren.',
'sharesAvailable' => $sharesAvailable,
'attributionHint' => $sharesAvailable ? ''
: 'Eigenverbrauch und Autarkie sind bei unterschiedlichen Messgrenzen nicht eindeutig bestimmbar. Die Energiesummen bleiben separat sichtbar.',
'colors' => [ 'colors' => [
'selfConsumption' => EnergyChartCalculator::normalizeColor( 'selfConsumption' => EnergyChartCalculator::normalizeColor(
$this->ReadPropertyString('FarbeEigenverbrauch'), $this->ReadPropertyString('FarbeEigenverbrauch'),
@@ -270,7 +278,7 @@ class Energiediagramm extends IPSModule
self::COLOR_AUTARKY self::COLOR_AUTARKY
), ),
], ],
'values' => EnergyChartCalculator::metrics(0.0, 0.0, 0.0), 'values' => EnergyChartCalculator::metrics(0.0, 0.0, 0.0, null, $sharesAvailable),
]; ];
if (!$this->configurationIsValid()) { if (!$this->configurationIsValid()) {
@@ -326,7 +334,8 @@ class Energiediagramm extends IPSModule
$production['value'] * $factor, $production['value'] * $factor,
$feedIn['value'] * $factor, $feedIn['value'] * $factor,
$grid['value'] * $factor, $grid['value'] * $factor,
$consumption $consumption,
$sharesAvailable
); );
if (!$payload['hasData']) { if (!$payload['hasData']) {
+15 -6
View File
@@ -4,17 +4,19 @@ Unabhaengige Zusatzmodule fuer IP-Symcon. Dieses Repository ist nicht vom Enelix
## Status ## Status
Das Repository befindet sich im Aufbau. Verbrauchskostenreport, Version 0.1 Build 1 ist die erste Beta fuer kontrollierte Anlagen- und
Energiediagramm und Shelly Modul sind als installierbare IP-Symcon-Module Feldtests. Verbrauchskostenreport, Virtuelle Batterie, CC100 Hardware,
enthalten; die weiteren Module sind derzeit als Diskussionsentwürfe dokumentiert. Energiediagramm, VGT-Schnittstelle und Shelly Modul sind als installierbare
IP-Symcon-Module enthalten. Produktiver Einsatz setzt eine anlagenspezifische
Pruefung und Sicherung voraus.
## Module ## Module
- Verbrauchskostenreport (implementiert) - Verbrauchskostenreport (implementiert)
- Virtuelle Batterie - Virtuelle Batterie (implementiert)
- CC100 Hardware - CC100 Hardware (implementiert)
- Energiediagramm (implementiert) - Energiediagramm (implementiert)
- VGT-Schnittstelle - VGT-Schnittstelle (implementiert)
- Shelly Modul (implementiert) - Shelly Modul (implementiert)
Die vollständigen Tabellen mit Properties, Variablen, Verhalten und offenen Die vollständigen Tabellen mit Properties, Variablen, Verhalten und offenen
@@ -23,6 +25,10 @@ Punkten stehen in der [Modulübersicht](docs/module/README.md).
Prognose, Lizenzierung und Störüberwachung sind keine Module dieses Prognose, Lizenzierung und Störüberwachung sind keine Module dieses
Repositories. Die Utils-Module bleiben auch untereinander unabhängig. Repositories. Die Utils-Module bleiben auch untereinander unabhängig.
## Release-Hinweise
Die Aenderungen der ersten Beta stehen im [Changelog](CHANGELOG.md).
## Voraussetzungen ## Voraussetzungen
- IP-Symcon ab Version 8.0 - IP-Symcon ab Version 8.0
@@ -59,9 +65,12 @@ Aktualisierungen werden ausschließlich über die IP-Symcon-Modulverwaltung
bezogen. Nach einem Branchwechsel oder Update müssen alle implementierten bezogen. Nach einem Branchwechsel oder Update müssen alle implementierten
Module geladen sein: Module geladen sein:
- `CC100 Hardware`
- `Energiediagramm` - `Energiediagramm`
- `Shelly Modul` - `Shelly Modul`
- `VGT-Schnittstelle`
- `Verbrauchskostenreport` - `Verbrauchskostenreport`
- `Virtuelle Batterie`
Ein korrekter Zustand zeigt die Repository-URL, den ausgewählten Branch und Ein korrekter Zustand zeigt die Repository-URL, den ausgewählten Branch und
kein Warnsymbol bei `Enelix Utils`. 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);
}
}
+8
View File
@@ -19,6 +19,13 @@ Abmelden des WebHooks beim Löschen einer Instanz.
- erzeugt optional einen Schweizer QR-Zahlteil - erzeugt optional einen Schweizer QR-Zahlteil
- unterstützt den konfigurierbaren MWST-Ausweis - unterstützt den konfigurierbaren MWST-Ausweis
## Lizenzierung
Der Report benoetigt eine Grundlizenz. Zusaetzlich prueft das Modul getrennte
Kontingente fuer konfigurierte Stromzaehler und Nebenzaehler. Lizenzcodes werden
zur Laufzeit gegen `https://license.enelix.ch` geprueft; Zugangsdaten oder
Lizenzcodes werden nicht im Repository gespeichert.
## QR-Zahlteil ## QR-Zahlteil
Der QR-Zahlteil verwendet strukturierte Adressen (`S`) nach SIX SPS 2.3. Der QR-Zahlteil verwendet strukturierte Adressen (`S`) nach SIX SPS 2.3.
@@ -30,6 +37,7 @@ Benutzer- und Zahlungsempfängeradresse benötigen Name, PLZ, Ort und ISO-2-Land
| Property | Beschreibung | | Property | Beschreibung |
|---|---| |---|---|
| `Lizenzcode` | Laufzeitparameter fuer die Freigabe am Lizenzdienst |
| `Users` | Benutzerliste | | `Users` | Benutzerliste |
| `PowerMeters` | Stromzählerliste | | `PowerMeters` | Stromzählerliste |
| `WaterMeters` | Nebenkostenzähler | | `WaterMeters` | Nebenkostenzähler |
+36
View File
@@ -1,5 +1,41 @@
{ {
"elements": [ "elements": [
{
"type": "ExpansionPanel",
"caption": "Lizenzierung",
"expanded": true,
"items": [
{
"type": "ValidationTextBox",
"name": "Lizenzcode",
"caption": "Verbrauchskostenlizenzcode",
"placeholder": "ENX-XXXX-XXXX-XXXX-XXXX"
},
{
"type": "Label",
"name": "LizenzInstallationID",
"caption": "Installations-ID wird erzeugt"
},
{
"type": "Label",
"name": "LizenzInformation",
"caption": "Verbrauchskostenlizenzcode fehlt."
},
{
"type": "Button",
"name": "LizenzPruefen",
"caption": "Lizenz prüfen und binden",
"onClick": "IPS_RequestAction($id, 'FormLizenzPruefen', $Lizenzcode);"
},
{
"type": "Button",
"name": "LizenzVerwalten",
"caption": "Lizenz verwalten",
"link": true,
"onClick": "echo 'https://license.enelix.ch';"
}
]
},
{ {
"type": "Label", "type": "Label",
"caption": "🧾 Einstellungen Verbrauchskostenreport" "caption": "🧾 Einstellungen Verbrauchskostenreport"
+302
View File
@@ -36,6 +36,12 @@ class VerbrauchskostenreportPDF extends TCPDF
class Verbrauchskostenreport extends IPSModule class Verbrauchskostenreport extends IPSModule
{ {
private const LIZENZ_ENDPOINT = 'https://license.enelix.ch/api/v1/licenses/activate';
private const LIZENZ_RETRY_SEKUNDEN = 900;
private const KATALOG_GRUNDLIZENZ = 'cost_report';
private const KATALOG_STROMZAEHLER = 'cost_meter_power';
private const KATALOG_NEBENZAEHLER = 'cost_meter_aux';
/** @var array<int, array<string, array<string, float|string>>> */ /** @var array<int, array<string, array<string, float|string>>> */
private array $powerCostCache = []; private array $powerCostCache = [];
@@ -47,6 +53,7 @@ class Verbrauchskostenreport extends IPSModule
$this->RegisterPropertyString('PowerMeters', '[]'); $this->RegisterPropertyString('PowerMeters', '[]');
$this->RegisterPropertyString('WaterMeters', '[]'); $this->RegisterPropertyString('WaterMeters', '[]');
$this->RegisterPropertyString('Tariffs', '[]'); $this->RegisterPropertyString('Tariffs', '[]');
$this->RegisterPropertyString('Lizenzcode', '');
$this->RegisterPropertyInteger('LogoMediaID', 0); $this->RegisterPropertyInteger('LogoMediaID', 0);
$this->RegisterPropertyString('PropertyText', 'Liegenschaft'); $this->RegisterPropertyString('PropertyText', 'Liegenschaft');
$this->RegisterPropertyString('FooterText', 'Belevo AG • 6122 Menznau • www.belevo.ch'); $this->RegisterPropertyString('FooterText', 'Belevo AG • 6122 Menznau • www.belevo.ch');
@@ -64,6 +71,15 @@ class Verbrauchskostenreport extends IPSModule
$this->RegisterPropertyString('CreditorCountry', 'CH'); $this->RegisterPropertyString('CreditorCountry', 'CH');
$this->RegisterPropertyString('BankIBAN', ''); $this->RegisterPropertyString('BankIBAN', '');
$this->RegisterAttributeString('LizenzInstallationID', '');
$this->RegisterAttributeString('LizenzLease', '{}');
$this->RegisterAttributeString('LizenzcodeHash', '');
$this->RegisterAttributeString('LizenzLetzterFehler', '');
$this->RegisterAttributeInteger('LizenzNaechsterVersuch', 0);
if ($this->ReadAttributeString('LizenzInstallationID') === '') {
$this->WriteAttributeString('LizenzInstallationID', $this->ErzeugeUuidV4());
}
$this->RegisterTariffVariableProfiles(); $this->RegisterTariffVariableProfiles();
$this->RegisterVariableInteger('FromDate', 'Startdatum', '~UnixTimestamp', 1); $this->RegisterVariableInteger('FromDate', 'Startdatum', '~UnixTimestamp', 1);
@@ -106,6 +122,8 @@ class Verbrauchskostenreport extends IPSModule
$this->InitializeVisuTariffDefaults(); $this->InitializeVisuTariffDefaults();
$this->UpdateTariffOverview(); $this->UpdateTariffOverview();
$this->UpdateUserOverview(); $this->UpdateUserOverview();
$this->AktualisiereLizenz($this->ReadPropertyString('Lizenzcode'), false);
$this->AktualisiereLizenzForm();
} }
public function Destroy() public function Destroy()
@@ -630,6 +648,278 @@ form.onsubmit=function(){
return $this->SaveTariffTableFromPayload(json_encode($rows)); return $this->SaveTariffTableFromPayload(json_encode($rows));
} }
private function AktualisiereLizenz(string $lizenzcode, bool $erzwingen): bool
{
$code = $this->NormalisiereLizenzcode($lizenzcode);
if (!$this->IstLizenzcodeGueltig($code)) {
$this->WriteAttributeString(
'LizenzLetzterFehler',
$code === '' ? 'Verbrauchskostenlizenzcode fehlt.' : 'Verbrauchskostenlizenzcode ist ungültig.'
);
return false;
}
$installationID = $this->ReadAttributeString('LizenzInstallationID');
if ($installationID === '') {
$installationID = $this->ErzeugeUuidV4();
$this->WriteAttributeString('LizenzInstallationID', $installationID);
}
$codeHash = hash('sha256', $code);
$gespeicherterHash = $this->ReadAttributeString('LizenzcodeHash');
$lease = $this->LeseLizenzLease();
$lokalGueltig = false;
if ($gespeicherterHash !== '' && hash_equals($gespeicherterHash, $codeHash)) {
try {
$this->PruefeLizenzLease($lease, $installationID);
$lokalGueltig = true;
} catch (Throwable $fehler) {
$lokalGueltig = false;
}
}
$erneuernAb = strtotime((string)($lease['refreshAfter'] ?? '')) ?: 0;
if (!$erzwingen && $lokalGueltig && time() < $erneuernAb) {
return true;
}
if (!$erzwingen && time() < $this->ReadAttributeInteger('LizenzNaechsterVersuch')) {
return $lokalGueltig;
}
$this->WriteAttributeInteger(
'LizenzNaechsterVersuch',
time() + self::LIZENZ_RETRY_SEKUNDEN
);
try {
$neueLease = $this->RufeLizenzserverAuf($code, $installationID);
$this->PruefeLizenzLease($neueLease, $installationID);
$this->WriteAttributeString(
'LizenzLease',
json_encode($neueLease, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES)
);
$this->WriteAttributeString('LizenzcodeHash', $codeHash);
$this->WriteAttributeString('LizenzLetzterFehler', '');
$this->WriteAttributeInteger(
'LizenzNaechsterVersuch',
strtotime((string)$neueLease['refreshAfter']) ?: time() + 86400
);
return true;
} catch (Throwable $fehler) {
$this->WriteAttributeString(
'LizenzLetzterFehler',
substr($fehler->getMessage(), 0, 500)
);
return $lokalGueltig;
}
}
private function AktualisiereLizenzForm(?string $lizenzcode = null): void
{
$this->UpdateFormField(
'LizenzInstallationID',
'caption',
'Installations-ID: ' . $this->ReadAttributeString('LizenzInstallationID')
);
$this->UpdateFormField(
'LizenzInformation',
'caption',
$this->LizenzStatustext($lizenzcode)
);
}
private function LizenzStatustext(?string $lizenzcode = null): string
{
$code = $this->NormalisiereLizenzcode(
$lizenzcode ?? $this->ReadPropertyString('Lizenzcode')
);
if ($code === '') {
return 'Verbrauchskostenlizenzcode fehlt.';
}
if (!$this->IstLizenzcodeGueltig($code)) {
return 'Verbrauchskostenlizenzcode ist ungültig.';
}
$hash = $this->ReadAttributeString('LizenzcodeHash');
$fehler = $this->ReadAttributeString('LizenzLetzterFehler');
if ($hash === '' || !hash_equals($hash, hash('sha256', $code))) {
return $fehler !== '' ? 'Nicht freigegeben: ' . $fehler : 'Lizenz noch nicht bestätigt.';
}
try {
$lease = $this->LeseLizenzLease();
$this->PruefeLizenzLease(
$lease,
$this->ReadAttributeString('LizenzInstallationID')
);
$status = sprintf(
'Aktiv: %d Stromzähler und %d Wärme-/Wasserzähler lizenziert.',
$this->LizenzMenge($lease, self::KATALOG_STROMZAEHLER),
$this->LizenzMenge($lease, self::KATALOG_NEBENZAEHLER)
);
if ($fehler !== '') {
$status .= ' Online-Prüfung fehlgeschlagen; lokale Freigabe ist noch gültig. Fehler: '
. $fehler;
}
return $status;
} catch (Throwable $ungueltig) {
return 'Nicht freigegeben: ' . $ungueltig->getMessage();
}
}
private function PruefeLizenzLease(array $lease, string $installationID): void
{
if (($lease['status'] ?? null) !== 'active') {
throw new RuntimeException('Die Verbrauchskostenlizenz ist nicht aktiv.');
}
if (($lease['installationId'] ?? null) !== $installationID) {
throw new RuntimeException('Die Verbrauchskostenlizenz gehört zu einer anderen Installation.');
}
$offlineBis = strtotime((string)($lease['offlineUntil'] ?? ''));
if ($offlineBis === false || time() >= $offlineBis) {
throw new RuntimeException('Die Offline-Freigabe ist abgelaufen.');
}
if ($this->LizenzMenge($lease, self::KATALOG_GRUNDLIZENZ) < 1) {
throw new RuntimeException('Die Grundlizenz für den Verbrauchskostenreport fehlt.');
}
$strom = count($this->LeseJsonListe('PowerMeters'));
$neben = count($this->LeseJsonListe('WaterMeters'));
$stromLizenziert = $this->LizenzMenge($lease, self::KATALOG_STROMZAEHLER);
$nebenLizenziert = $this->LizenzMenge($lease, self::KATALOG_NEBENZAEHLER);
if ($strom > $stromLizenziert) {
throw new RuntimeException(sprintf(
'Stromzähler: %d konfiguriert, aber nur %d lizenziert.',
$strom,
$stromLizenziert
));
}
if ($neben > $nebenLizenziert) {
throw new RuntimeException(sprintf(
'Wärme-/Wasserzähler: %d konfiguriert, aber nur %d lizenziert.',
$neben,
$nebenLizenziert
));
}
}
private function LizenzMenge(array $lease, string $kennung): int
{
$menge = 0;
foreach (($lease['entitlements'] ?? []) as $berechtigung) {
if (!is_array($berechtigung) || ($berechtigung['status'] ?? null) !== 'active') {
continue;
}
$schluessel = $berechtigung['catalog_key'] ?? $berechtigung['sku'] ?? null;
$anzahl = $berechtigung['quantity'] ?? 0;
if ($schluessel === $kennung && is_int($anzahl) && $anzahl > 0) {
$menge += $anzahl;
}
}
return $menge;
}
private function LeseJsonListe(string $property): array
{
try {
$werte = json_decode(
$this->ReadPropertyString($property),
true,
512,
JSON_THROW_ON_ERROR
);
return is_array($werte) ? array_values($werte) : [];
} catch (Throwable $fehler) {
return [];
}
}
private function LeseLizenzLease(): array
{
try {
$lease = json_decode(
$this->ReadAttributeString('LizenzLease'),
true,
512,
JSON_THROW_ON_ERROR
);
return is_array($lease) ? $lease : [];
} catch (Throwable $fehler) {
return [];
}
}
private function RufeLizenzserverAuf(string $lizenzcode, string $installationID): array
{
$curl = curl_init(self::LIZENZ_ENDPOINT);
if ($curl === false) {
throw new RuntimeException('Die HTTPS-Anfrage konnte nicht vorbereitet werden.');
}
$nutzlast = json_encode([
'code' => $lizenzcode,
'installationId' => $installationID,
], JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES);
try {
curl_setopt_array($curl, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $nutzlast,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CONNECTTIMEOUT => 5,
CURLOPT_TIMEOUT => 10,
CURLOPT_HTTPHEADER => ['Accept: application/json', 'Content-Type: application/json'],
CURLOPT_USERAGENT => 'Enelix-Verbrauchskostenreport/0.1',
]);
$antwort = curl_exec($curl);
if ($antwort === false) {
throw new RuntimeException('Lizenzserver nicht erreichbar: ' . curl_error($curl));
}
if (strlen($antwort) > 65536) {
throw new RuntimeException('Die Antwort des Lizenzservers ist zu gross.');
}
$httpStatus = (int)curl_getinfo($curl, CURLINFO_HTTP_CODE);
} finally {
curl_close($curl);
}
$daten = json_decode($antwort, true, 512, JSON_THROW_ON_ERROR);
if (!is_array($daten)) {
throw new RuntimeException('Der Lizenzserver lieferte kein JSON-Objekt.');
}
if ($httpStatus !== 200) {
throw new RuntimeException(
'Lizenzserver HTTP ' . $httpStatus . ': '
. (is_string($daten['error'] ?? null) ? $daten['error'] : 'Unbekannter Fehler')
);
}
return $daten;
}
private function NormalisiereLizenzcode(string $code): string
{
return strtoupper((string)preg_replace('/[^A-Z0-9]/i', '', $code));
}
private function IstLizenzcodeGueltig(string $code): bool
{
return preg_match('/^ENX[A-HJ-NP-Z2-9]{16}$/', $code) === 1;
}
private function ErzeugeUuidV4(): string
{
$bytes = random_bytes(16);
$bytes[6] = chr((ord($bytes[6]) & 0x0f) | 0x40);
$bytes[8] = chr((ord($bytes[8]) & 0x3f) | 0x80);
$hex = bin2hex($bytes);
return sprintf(
'%s-%s-%s-%s-%s',
substr($hex, 0, 8),
substr($hex, 8, 4),
substr($hex, 12, 4),
substr($hex, 16, 4),
substr($hex, 20, 12)
);
}
private function ReadUsers() private function ReadUsers()
{ {
$users = json_decode($this->ReadPropertyString('Users'), true); $users = json_decode($this->ReadPropertyString('Users'), true);
@@ -1348,6 +1638,14 @@ refreshPickers();
public function RequestAction($Ident, $Value) public function RequestAction($Ident, $Value)
{ {
switch ($Ident) { switch ($Ident) {
case 'FormLizenzPruefen':
if (!is_string($Value)) {
throw new InvalidArgumentException('Der Lizenzcode muss als Text übergeben werden.');
}
$this->AktualisiereLizenz($Value, true);
$this->AktualisiereLizenzForm($Value);
break;
case 'FromDate': case 'FromDate':
case 'ToDate': case 'ToDate':
case 'VisuTariffType': case 'VisuTariffType':
@@ -1458,6 +1756,10 @@ refreshPickers();
public function GenerateReport() public function GenerateReport()
{ {
if (!$this->AktualisiereLizenz($this->ReadPropertyString('Lizenzcode'), false)) {
throw new RuntimeException($this->LizenzStatustext());
}
$from = GetValue($this->GetIDForIdent('FromDate')); $from = GetValue($this->GetIDForIdent('FromDate'));
$to = GetValue($this->GetIDForIdent('ToDate')); $to = GetValue($this->GetIDForIdent('ToDate'));
+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);
}
}
+1
View File
@@ -2,6 +2,7 @@
"name": "belevo/enelix-utils", "name": "belevo/enelix-utils",
"description": "Unabhaengige Zusatzmodule fuer IP-Symcon", "description": "Unabhaengige Zusatzmodule fuer IP-Symcon",
"type": "library", "type": "library",
"license": "proprietary",
"require": { "require": {
"php": ">=8.0" "php": ">=8.0"
}, },
+125 -18
View File
@@ -1,29 +1,136 @@
# CC100 Hardware # 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, Das Modul bindet die komplette Onboard-I/O des CC100 direkt ueber die von WAGO
Anzeigename, Aktivierung und Abfrageintervall werden je Eintrag festgelegt. 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 | ## Kanalauswahl
| --- | --- | --- |
| Dynamisch je Kanal | passend / Anzeige oder bedienbar | Nur ausgewählte Kanäle; DO/AO erst nach geprüfter Zuordnung bedienbar. | Die Property 'Kanalauswahl' enthaelt alle 18 Kanaele. Pro Zeile werden
| `Hardwarestatus` | Integer / Anzeige | Anschlussstatus. | Aktivierung, Typ, Kanalnummer, Anzeigename und Abfrageintervall festgelegt.
| `Stoertext` | String / Anzeige | Zugriffs- oder Messfehler. | 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 ## Properties
| Ident | Typ | Standard / Beschreibung | | Property | Standard | Bedeutung |
| --- | --- | --- | | --- | --- | --- |
| `Kanalauswahl` | String/JSON | `[]`; Kanalart, Nummer, Anzeigename, Aktiv und Abfrageintervall in ms je Zeile. | | 'Kanalauswahl' | alle Kanaele inaktiv | Sichtbare und abgefragte Kanaele |
| `StartverhaltenAusgaenge` | Auswahl | `Lesen/uebernehmen` oder definierte Startwerte schreiben. | | 'DigitalInputPath' | WAGO-DI-Sammelpfad | 8-Bit-Eingangswert, LSB ist DI1 |
| `StartwerteAusgaenge` | String/JSON | `{}`; nur bei ausdrücklich gewähltem Schreibmodus. | | '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. ## Aktualisierung
- Nur ausgewählte Kanäle werden angelegt, abgefragt oder bedient.
- CC100-Hardwarepfade, Skalierung und Schreibbarkeit von AI/AO prüfen. Die CC100-Treiber stellen die Onboard-I/O als lesbare beziehungsweise
- RS485-Schnittstelle bleibt ein eigener Prüfauftrag. 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 # Modulübersicht Enelix Utils
> Status: Verbrauchskostenreport, Energiediagramm und Shelly Modul sind > Status: Alle dokumentierten Enelix-Utils-Module sind implementiert.
> implementiert. Die übrigen Module sind als Diskussionsentwürfe dokumentiert.
| Modul | Herkunft | EMS-Abhängigkeit | | Modul | Herkunft | EMS-Abhängigkeit |
| --- | --- | --- | | --- | --- | --- |
| [Verbrauchskostenreport](Verbrauchskostenreport/README.md) | Kosten- und Verbrauchsauswertung | Keine | | [Verbrauchskostenreport](Verbrauchskostenreport/README.md) | Kosten- und Verbrauchsauswertung | Keine |
| [Virtuelle Batterie](Virtuelle-Batterie/README.md) | Bat_EV_SDL_V4 | Optionaler Vertrag, keine Code-Abhängigkeit | | [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 | Keine | | [CC100 Hardware](CC100-Hardware/README.md) | CC100_HW (implementiert) | Keine |
| [Energiediagramm](Energiediagramm/README.md) | Energy_Pie (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 | | [Shelly Modul](Shelly-Modul/README.md) | Shelly_Parser_MQTT | Keine |
Utils-Module beeinflussen sich nicht gegenseitig. Prognose, Lizenzierung und Utils-Module beeinflussen sich nicht gegenseitig. Prognose, Lizenzierung und
Störüberwachung sind keine Module dieses Repositories. Störüberwachung sind keine Module dieses Repositories.
`0*` bezeichnet einen noch nicht eingerichteten Anlagenwert. Erst nach der `0*` bezeichnet einen noch nicht eingerichteten Anlagenwert. Jedes Modul
Teamfreigabe erhält ein Modul `module.json`, `form.json`, `module.php` und besitzt `module.json`, `form.json`, `module.php`, Unit-Tests und einen
zugehörige Unit-Tests. registrierten IP-Symcon-Laufzeittest.
+92 -31
View File
@@ -1,42 +1,103 @@
# VGT-Schnittstelle # VGT-Schnittstelle
> Status: Diskussionsentwurf. Führt `MQTTPVSDL` und die vorhandenen > Status: Implementiert fuer IP-Symcon 8.0. Fuehrt `MQTTPVSDL` und
> Batterie-Felder aus `MQTTBatterySDL` zusammen. > `MQTTBatterySDL` zusammen.
Die bestehende MQTT-Arbeitsweise und technische Alt-Idents bleiben erhalten. Die bestehende MQTT-Schnittstelle wurde unveraendert uebernommen. Weder
Die Geräteart PV oder Batterie steuert nur die sichtbaren Felder. 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. | | `activate` | Setpoint zwischen 0 und Maximum | invertierter Setpoint |
| `IsRunning` | Boolean / Anzeige | Bestehender Bearbeitungsstatus. | | `stop` | Freigabe auf konfiguriertes Maximum | 0 W |
| `MinSoC` | Float / Anzeige | Nur Batterie; untere Ladezustandsgrenze. | | `restore` | keine aktive Fernbegrenzung | Regelung auf `TargetSoC` |
| `MaxSoC` | Float / Anzeige | Nur Batterie; obere Ladezustandsgrenze. |
| `PowerSetpoint` | Integer / bedienbar | Bestehende Leistungsvorgabe und Testaktion. | Im Batteriemodus verhindern `MinSoC` und `MaxSoC` eine Vorgabe in die
| `Strategy` | String / bedienbar | Bestehende Strategie und Testaktion. | falsche Richtung an der jeweiligen Ladezustandsgrenze.
| `LastReadResponse` | String / Anzeige | Letzte Lese-Antwort. |
| `LastWriteResponse` | String / Anzeige | Letzte Steuer-Antwort. |
## Properties ## Properties
| Technischer Ident | Typ | Standard / Beschreibung | | Property | Standard | Beschreibung |
| --- | --- | --- | | --- | ---: | --- |
| `Geraeteart` | Auswahl | `PV`; alternativ `Batterie`. | | `Geraeteart` | PV | PV oder Batterie |
| `TopicSuffix` | String | leer; bestehender MQTT-Suffix. | | `TopicSuffix` | leer | Unveraenderter MQTT-Suffix |
| `ReqActionID` | Integer | `0`; bestehende Ausgabevariable/Nennleistung. | | `ReqActionID` | 0 | Bedienbare Zielvariable |
| `PowerProductionID` | Integer | `0`; aktuelle SDL-Leistung. | | `PowerProductionID` | 0 | Aktuelle SDL-/PV-Leistung |
| `SoCID` | Integer | `0`; nur bei Batterie. | | `SoCID` | 0 | Ladezustand im Batteriemodus |
| `TargetSoC` | Float | `50` %; nur bei Batterie. | | `TargetSoC` | 50 % | Zielwert fuer `restore` |
| `ChargePower` | Integer | `2500` W; nur bei Batterie. | | `ChargePower` | 2500 W | Ladeleistung fuer `restore` |
| `DischargePower` | Integer | `2500` W; nur bei Batterie. | | `DischargePower` | 2500 W | Entladeleistung fuer `restore` |
| `MaxPowerSetpoint` | Integer | `10000` W. | | `MaxPowerSetpoint` | 10000 W | Begrenzung eingehender Setpoints |
| `LoggingEin` | false | Debug-Ausgaben |
## Verhalten und offene Punkte ## Robustheit
- MQTT-Parent, Auftragsauswertung, Timer, Aktionen und vorhandener Testknopf - Zielvariablen muessen numerisch und bedienbar sein.
bleiben fachlicher Ausgangspunkt. - MQTT-Antworten werden in einer FIFO-Warteschlange verarbeitet; schnelle
- Passende MQTT-Verbindung zuordnen oder bei der Instanziierung anlegen. parallele Anfragen ueberschreiben sich nicht mehr.
- Keine neuen Zieladapter, Protokolle oder Datenqualitätsfelder in diesem Schritt. - Fehler der Zielaktion werden als Instanzstatus und Stoertext angezeigt.
- Optionale EMS-Anbindung darf keine feste Repository-Abhängigkeit erzeugen. - 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.
@@ -7,6 +7,12 @@
Der Modulpräfix lautet `VKR`. Der Report wird über die Aktion `CreateReport` Der Modulpräfix lautet `VKR`. Der Report wird über die Aktion `CreateReport`
erstellt und im Medium `ReportPDF` bereitgestellt. erstellt und im Medium `ReportPDF` bereitgestellt.
## Lizenzierung
Die Freigabe besteht aus einer Grundlizenz und getrennten Kontingenten fuer
Stromzaehler und Nebenzaehler. Der konfigurierte Umfang darf die vom
Lizenzdienst bestaetigten Mengen nicht ueberschreiten.
## Variablen ## Variablen
| Technischer Ident / Anzeige | Typ / Zugriff | Beschreibung | | Technischer Ident / Anzeige | Typ / Zugriff | Beschreibung |
@@ -28,6 +34,7 @@ erstellt und im Medium `ReportPDF` bereitgestellt.
| Technischer Ident / Anzeige | Typ | Standard / Beschreibung | | Technischer Ident / Anzeige | Typ | Standard / Beschreibung |
| --- | --- | --- | | --- | --- | --- |
| `Lizenzcode` / Lizenzcode | String | leer; wird zur Laufzeit am Lizenzdienst geprueft. |
| `Users` / Benutzer | String/JSON | `[]`; Benutzerstruktur. | | `Users` / Benutzer | String/JSON | `[]`; Benutzerstruktur. |
| `PowerMeters` / Stromzähler | String/JSON | `[]`; Zuordnungen. | | `PowerMeters` / Stromzähler | String/JSON | `[]`; Zuordnungen. |
| `WaterMeters` / Wasserzähler | String/JSON | `[]`; Zuordnungen. | | `WaterMeters` / Wasserzähler | String/JSON | `[]`; Zuordnungen. |
+105 -58
View File
@@ -1,68 +1,115 @@
# Virtuelle Batterie # Virtuelle Batterie
> Status: Diskussionsentwurf. Übernimmt `Bat_EV_SDL_V4` für die Bereiche > Status: Implementiert fuer IP-Symcon 8.0. Adaptiert das Grundkonzept aus
> Eigenverbrauch und SDL. > `Bat_EV_SDL_V4`, ohne dessen herstellerspezifische Typnamen zu uebernehmen.
Batterien und VGT-Kommunikation werden ausgewählt. Das Modul bleibt ohne EMS Das Modul fasst mehrere physische Batterien zu genau einer grossen virtuellen
nutzbar; eine EMS-Anbindung verwendet optional denselben Nachrichtenvertrag, Batterie zusammen. Diese Gesamtbatterie wird in einen Eigenverbrauchs- und
ohne Code-Abhängigkeit zwischen den Repositories. einen SDL-Anteil aufgeteilt. SDL hat bei der Leistungsverteilung Vorrang.
## Variablen ## Verantwortlichkeiten
| Ident | Typ / Zugriff | Beschreibung | ```text
| --- | --- | --- | Enelix Batterie-Modul -> EV-Proxyregister --+
| `Aktiv` | Boolean / bedienbar | Koordination aktivieren. | +-> Virtuelle Batterie -> physische Sollwerte
| `EigenverbrauchSollleistung` | Float / bedingt bedienbar | W; nur bei lokaler Quelle schreiben. | VGT-Schnittstelle ----> SDLSollleistung ----+
| `SDLSollleistung` | Float / Anzeige | W; nur aus gültigem zeitlichem Auftrag. | ```
| `EigenverbrauchIstleistung` | Float / Anzeige | W; zugeordneter Anteil. |
| `SDLIstleistung` | Float / Anzeige | W; zugeordneter Anteil. | Die virtuelle Batterie ist die einzige Instanz, die die konfigurierten
| `GesamtIstleistung` | Float / Anzeige | W; Summe gültiger physischer Messwerte. | physischen Sollleistungsvariablen beschreibt. Das bestehende EMS-Batteriemodul
| `Leistungsquelle` | Integer / Anzeige | `0` fehlt, `1` berechnet, `2` gemessen. | regelt den Eigenverbrauchsanteil. Die VGT-Schnittstelle regelt ausschliesslich
| `EigenverbrauchLadezustand` | Float / Anzeige | Virtueller Füllstand in %. | den SDL-Anteil.
| `SDLLadezustand` | Float / Anzeige | Virtueller Füllstand in %. |
| `EigenverbrauchMaxLaden` | Float / Anzeige | Verfügbare Ladeleistung in W. | Positive Leistung bedeutet Laden, negative Leistung bedeutet Entladen.
| `EigenverbrauchMaxEntladen` | Float / Anzeige | Verfügbare Entladeleistung in W. |
| `SDLMaxLaden` | Float / Anzeige | Verfügbare Ladeleistung in W. | ## Physische Batterien
| `SDLMaxEntladen` | Float / Anzeige | Verfügbare Entladeleistung in W. |
| `EigenverbrauchStart` | Float / bedienbar | Startwert 0–100 % für explizites Rücksetzen. | Die Property `Batterieliste` enthaelt pro Batterie:
| `SDLStart` | Float / bedienbar | Startwert 0–100 % für explizites Rücksetzen. |
| `VirtuelleKontenRuecksetzen` | Boolean / Impuls | `true` löst aus und wird wieder zurückgesetzt. | | Feld | Bedeutung |
| `Managerstatus` | Integer / Anzeige | Nur bei Managerquelle. | | --- | --- |
| `VGTStatus` | Integer / Anzeige | Nur bei VGT-Quelle. | | `Name` | Lesbare Kennung |
| `Stoerung` | Boolean / Anzeige | Eigene oder zugeordnete Fehler. | | `Kapazitaet_kWh` | Nutzbare Kapazitaet |
| `Stoertext` | String / Anzeige | Diagnose. | | `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 ## Properties
| Ident | Typ | Standard / Beschreibung | | Property | Standard | Beschreibung |
| --- | --- | --- | | --- | ---: | --- |
| `Batterieliste` | String/JSON | `[]`; Zielstelle, Kapazität, Grenzen und Messquellen je Batterie. | | `Batterieliste` | `[]` | Physische Batterien und Zielvariablen |
| `Eigenverbrauchsquelle` | Integer | `0` lokal, `1` externe Variable, `2` Managervertrag. | | `SDLReserveLaden` | 0 W | Fuer SDL reservierte Ladeleistung |
| `EigenverbrauchSollVariableID` | Integer | `0`; Pflicht bei Quelle 1. | | `SDLReserveEntladen` | 0 W | Fuer SDL reservierte Entladeleistung |
| `SDLQuelle` | Integer | `0` keine, `1` VGT-Instanz, `2` externer Auftragsdatensatz. | | `Reservezeit` | 0.5 h | Energetische Reserve je Richtung |
| `VGTInstanzID` | Integer | `0`; Pflicht bei SDLQuelle 1. | | `Aktualisierungsintervall` | 2 s | Zyklische Sicherheitsaktualisierung |
| `SDLAuftragVariableID` | Integer | `0`; Pflicht bei Quelle 2, inklusive Gültigkeit. | | `MesswertMaxAlter` | 30 s | Zulaessiges Alter physischer Messwerte |
| `SDLReserveLaden` | Float | `0` W. | | `VorgabeTimeout` | 30 s | Gueltigkeit der EV- und SDL-Vorgaben |
| `SDLReserveEntladen` | Float | `0` W. | | `EigenverbrauchStart` | 50 % | Startwert des EV-Kontos |
| `Reservezeit` | Float | `0.5` h; Bereich 0–24. | | `SDLStart` | 50 % | Startwert des SDL-Kontos |
| `Aktualisierungsintervall` | Integer | `2` s. | | `LoggingEin` | false | Debug-Ausgaben |
| `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. |
## Verhalten und offene Punkte ## Verhalten und Sicherheit
Eigenverbrauch und SDL ergeben zusammen genau einen begrenzten Batteriesollwert. - SDL wird vor dem Eigenverbrauch auf die reservierte Leistung begrenzt.
Pro physischer Batterie darf nur eine Stelle führen. Messgrundlage und - Gleichgerichtete Eigenverbrauchsleistung erhaelt nur die verbleibende
Aufteilung der berechneten Leistungsanteile sind im Test eindeutig zu belegen. 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.
+10 -10
View File
@@ -42,8 +42,8 @@ werden, wenn sie vor dem Lauf nicht existierten.
- `single`: genau die als Auswahl übergebenen Module - `single`: genau die als Auswahl übergebenen Module
- `affected`: die durch geänderte Pfade ermittelten Module - `affected`: die durch geänderte Pfade ermittelten Module
Verfügbare Module: `Energiediagramm`, `ShellyModul`, Verfügbare Module: `CC100Hardware`, `Energiediagramm`, `ShellyModul`,
`Verbrauchskostenreport`. `VGTSchnittstelle`, `Verbrauchskostenreport` und `VirtuelleBatterie`.
Die Utils-Module werden jeweils eigenständig und ohne Abhängigkeit zu Enelix EMS getestet. Die Utils-Module werden jeweils eigenständig und ohne Abhängigkeit zu Enelix EMS getestet.
@@ -84,15 +84,15 @@ Zusätzlich schreibt der Runner die Zusammenfassung in das IP-Symcon-Log.
## CI-Regeln ## CI-Regeln
Bei jedem Push laufen sämtliche PHPUnit-Tests. Auf `develop` werden zusätzlich Bei jedem Push laufen sämtliche PHPUnit-Tests und die PHP-Syntaxprüfung. Die
die von den geänderten Pfaden betroffenen Symcon-Modultests ausgeführt. Bei Symcon-Modultests werden bis zur Verfügbarkeit eines geschützten Runners auf
gemeinsamen Framework- oder Vertragsänderungen läuft die vollständige Suite. der isolierten IP-Symcon-8.0-Instanz des Agent-Servers ausgeführt. Vor jeder
Für `beta` ist immer `all` vorgeschrieben. Übernahme nach `beta` ist ein erfolgreicher Lauf im Modus `all` verbindlich.
Der erzeugte JSON- und JUnit-Bericht gehört zum Freigabenachweis.
Die Symcon-Jobs benötigen einen geschützten Runner mit dem Label `symcon-8`, Eine spätere CI-Automatisierung benötigt einen geschützten Runner mit dem Label
lokalem Zugriff auf eine isolierte IP-Symcon-8.x-Instanz und den installierten `symcon-8`, lokalem Zugriff auf die isolierte IP-Symcon-Instanz und den exakt
Repository-Stand. Ohne diesen Runner ist die Richtlinie dokumentiert, aber zu prüfenden Repository-Stand.
nicht technisch durchsetzbar.
## Checkliste für neue Module ## Checkliste für neue Module
+3 -3
View File
@@ -1,12 +1,12 @@
{ {
"id": "{05DE4157-F0E8-4CA9-8B5E-D6AB9310775F}", "id": "{05DE4157-F0E8-4CA9-8B5E-D6AB9310775F}",
"name": "Enelix Utils", "name": "Enelix Utils",
"author": "Belevo AG", "author": "Enelix",
"url": "https://git.belevo.ch/ENELIX/Enelix-Utils", "url": "https://git.belevo.ch/ENELIX/Enelix-Utils",
"compatibility": { "compatibility": {
"version": "8.0" "version": "8.0"
}, },
"version": "0.1", "version": "0.1",
"build": 0, "build": 1,
"date": 0 "date": 1790662541
} }
+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 [ return [
'Verbrauchskostenreport' => ['Verbrauchskostenreport', 'Status: Implementiert'], 'Verbrauchskostenreport' => ['Verbrauchskostenreport', 'Status: Implementiert'],
'Virtuelle Batterie' => ['Virtuelle-Batterie', 'Status: Diskussionsentwurf'], 'Virtuelle Batterie' => ['Virtuelle-Batterie', 'Status: Implementiert'],
'CC100 Hardware' => ['CC100-Hardware', 'Status: Diskussionsentwurf'], 'CC100 Hardware' => ['CC100-Hardware', 'Status: Implementiert'],
'Energiediagramm' => ['Energiediagramm', 'Status: Implementiert'], 'Energiediagramm' => ['Energiediagramm', 'Status: Implementiert'],
'VGT-Schnittstelle' => ['VGT-Schnittstelle', 'Status: Diskussionsentwurf'], 'VGT-Schnittstelle' => ['VGT-Schnittstelle', 'Status: Implementiert'],
'Shelly-Modul' => ['Shelly-Modul', 'Status: Implementiert'], 'Shelly-Modul' => ['Shelly-Modul', 'Status: Implementiert'],
]; ];
} }
+36 -2
View File
@@ -33,6 +33,7 @@ final class EnergiediagrammModulstrukturTest extends TestCase
'EinspeisungZaehlerID', 'EinspeisungZaehlerID',
'NetzbezugZaehlerID', 'NetzbezugZaehlerID',
'VerbrauchZaehlerID', 'VerbrauchZaehlerID',
'EnergieanteileBerechenbar',
'Standardzeitraum', 'Standardzeitraum',
'FarbeEigenverbrauch', 'FarbeEigenverbrauch',
'FarbeAutarkie', 'FarbeAutarkie',
@@ -49,11 +50,40 @@ final class EnergiediagrammModulstrukturTest extends TestCase
self::assertStringContainsString('__ENELIX_INITIAL_STATE__', $html); self::assertStringContainsString('__ENELIX_INITIAL_STATE__', $html);
self::assertStringContainsString('ResizeObserver', $html); self::assertStringContainsString('ResizeObserver', $html);
self::assertStringContainsString('grid-template-columns: minmax(0, 1fr)', $html); self::assertStringContainsString('grid-template-columns: minmax(0, 1fr)', $html);
self::assertStringContainsString('padding: 58px 14px 14px', $html);
self::assertStringContainsString('padding: 58px 10px 10px', $html);
self::assertStringContainsString('background-color: var(--card-color, transparent)', $html);
self::assertStringContainsString('color: var(--content-color, CanvasText)', $html);
self::assertStringContainsString('function syncColorScheme()', $html);
self::assertStringContainsString('SetCustomRange', $html); self::assertStringContainsString('SetCustomRange', $html);
self::assertStringNotContainsString("addEventListener('load'", $html); self::assertStringNotContainsString("addEventListener('load'", $html);
} }
public function testModuleEmbedsInitialPayloadForTileAndPopup(): void public function testOriginalEnergyPieBackgroundStaysReadableAndNonInteractive(): void
{
$html = file_get_contents(__DIR__ . '/../Energiediagramm/module.html');
self::assertNotFalse($html);
self::assertMatchesRegularExpression('/#energy-app\\s*\\{[^}]*--card-color: #121216;/s', $html);
self::assertMatchesRegularExpression('/#energy-app\\s*\\{[^}]*--content-color: #f5f5f7;/s', $html);
self::assertMatchesRegularExpression('/#energy-app\\s*\\{[^}]*color-scheme: dark;/s', $html);
self::assertMatchesRegularExpression('/#energy-app\\s*\\{[^}]*isolation: isolate;/s', $html);
self::assertSame(1, preg_match('/#energy-app::before\\s*\\{([^}]+)\\}/s', $html, $background));
foreach ([
'inset: 0',
'z-index: -1',
'radial-gradient(520px 420px at 18% 18%, rgba(99, 179, 255, 0.42), transparent 60%)',
'radial-gradient(560px 460px at 82% 28%, rgba(168, 85, 247, 0.38), transparent 62%)',
'radial-gradient(620px 520px at 55% 85%, rgba(99, 179, 255, 0.26), transparent 64%)',
'filter: blur(18px)',
'pointer-events: none',
] as $style) {
self::assertStringContainsString($style, $background[1]);
}
self::assertStringContainsString('overflow: auto', $html);
self::assertStringNotContainsString('touch-action: none', $html);
}
public function testModuleEmbedsInitialPayloadAndEnablesSupportedVisualization(): void
{ {
$module = file_get_contents(__DIR__ . '/../Energiediagramm/module.php'); $module = file_get_contents(__DIR__ . '/../Energiediagramm/module.php');
self::assertNotFalse($module); self::assertNotFalse($module);
@@ -61,7 +91,11 @@ final class EnergiediagrammModulstrukturTest extends TestCase
"str_replace('__ENELIX_INITIAL_STATE__'", "str_replace('__ENELIX_INITIAL_STATE__'",
$module $module
); );
self::assertStringContainsString('GetVisualizationPopup', $module); self::assertStringContainsString(
"version_compare(IPS_GetKernelVersion(), '9.0', '>=')",
$module
);
self::assertStringContainsString('SetVisualizationType($visualizationType)', $module);
self::assertStringContainsString('Standardzeitraum', $module); self::assertStringContainsString('Standardzeitraum', $module);
self::assertStringContainsString('CustomStart', $module); self::assertStringContainsString('CustomStart', $module);
self::assertStringContainsString('CustomEnd', $module); self::assertStringContainsString('CustomEnd', $module);
+37 -1
View File
@@ -110,9 +110,45 @@ final class EnergiediagrammTest extends TestCase
'#2F80ED', '#2F80ED',
EnergyChartCalculator::normalizeColor('#2f80ed', '#000000') EnergyChartCalculator::normalizeColor('#2f80ed', '#000000')
); );
self::assertSame(
'#00FF4C',
EnergyChartCalculator::normalizeColor('65356', '#000000')
);
self::assertSame(
'#000000',
EnergyChartCalculator::normalizeColor('0', '#FFFFFF')
);
self::assertSame( self::assertSame(
'#22A06B', '#22A06B',
EnergyChartCalculator::normalizeColor('transparent', '#22a06b') EnergyChartCalculator::normalizeColor('16777216', '#22a06b')
);
self::assertSame(
'#22A06B',
EnergyChartCalculator::normalizeColor('-1', '#22a06b')
); );
} }
public function testMixedMeasurementBoundariesKeepTotalsWithoutInventingShares(): void
{
$values = EnergyChartCalculator::metrics(10.0, 0.0, 10.0, 5.0, false);
self::assertSame(10.0, $values['Produktion']);
self::assertSame(0.0, $values['Einspeisung']);
self::assertSame(10.0, $values['Netzbezug']);
self::assertSame(5.0, $values['Hausverbrauch']);
self::assertNull($values['Eigenverbrauch']);
self::assertNull($values['Eigenverbrauchsquote']);
self::assertNull($values['Autarkiegrad']);
}
public function testUnavailableSharesRemainNullWithZeroEnergy(): void
{
$unavailable = EnergyChartCalculator::metrics(0.0, 0.0, 0.0, null, false);
self::assertNull($unavailable['Eigenverbrauchsquote']);
self::assertNull($unavailable['Autarkiegrad']);
$available = EnergyChartCalculator::metrics(0.0, 0.0, 0.0);
self::assertSame(0.0, $available['Eigenverbrauchsquote']);
self::assertSame(0.0, $available['Autarkiegrad']);
}
} }
+88
View File
@@ -0,0 +1,88 @@
'use strict';
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const test = require('node:test');
const vm = require('node:vm');
function visualization(initialState) {
const elements = new Map();
const element = id => {
if (!elements.has(id)) {
elements.set(id, {
innerHTML: '', textContent: '', value: '', hidden: false,
addEventListener() {}, classList: { toggle() {} }
});
}
return elements.get(id);
};
const window = { setTimeout() {} };
const document = {
getElementById: element,
documentElement: { style: {} },
addEventListener() {}
};
const html = fs.readFileSync(path.join(__dirname, '../Energiediagramm/module.html'), 'utf8');
const script = html.match(/<script>([\s\S]*?)<\/script>/)[1]
.replace('__ENELIX_INITIAL_STATE__', JSON.stringify(initialState));
vm.runInNewContext(script, {
window, document,
getComputedStyle: () => ({ getPropertyValue: () => '' })
});
return { element, window };
}
function payload(overrides = {}) {
return {
range: 'day', date: '2026-10-06', hasData: true,
values: {
Produktion: 10, Hausverbrauch: 5, Netzbezug: 10, Einspeisung: 0,
Eigenverbrauchsquote: null, Autarkiegrad: null
},
...overrides
};
}
test('unavailable shares show neutral rings while preserving energy totals', () => {
const ui = visualization(payload({ sharesAvailable: false, attributionHint: 'Different measurement boundaries.' }));
for (const id of ['self-consumption-chart', 'autarky-chart']) {
const html = ui.element(id).innerHTML;
assert.match(html, /Nicht bestimmbar/);
assert.doesNotMatch(html, /donut-value|0\.0%/);
}
assert.match(ui.element('summary').innerHTML, /Produktion/);
assert.match(ui.element('summary').innerHTML, /10\.00 kWh/);
assert.match(ui.element('summary').innerHTML, /5\.00 kWh/);
assert.equal(ui.element('message').hidden, false);
assert.equal(ui.element('message').textContent, 'Different measurement boundaries.');
});
test('unavailable flag cannot display stale numeric percentages', () => {
const state = payload({ sharesAvailable: false });
state.values.Eigenverbrauchsquote = 75;
state.values.Autarkiegrad = 50;
const ui = visualization(state);
assert.doesNotMatch(ui.element('self-consumption-chart').innerHTML, /75\.0%/);
assert.doesNotMatch(ui.element('autarky-chart').innerHTML, /50\.0%/);
});
test('legacy numeric percentages and later recovery remain compatible', () => {
const ui = visualization(payload({ sharesAvailable: false }));
const state = payload();
state.values.Eigenverbrauchsquote = 75;
state.values.Autarkiegrad = 81.25;
ui.window.handleMessage(JSON.stringify(state));
assert.match(ui.element('self-consumption-chart').innerHTML, /75\.0%/);
assert.match(ui.element('autarky-chart').innerHTML, /81\.3%/);
assert.equal(ui.element('message').hidden, true);
});
test('missing archive values do not hide the attribution limitation', () => {
const ui = visualization(payload({
hasData: false, noDataHint: 'No archive data.',
sharesAvailable: false, attributionHint: 'Different measurement boundaries.'
}));
assert.equal(ui.element('message').textContent,
'No archive data. Different measurement boundaries.');
});
+4
View File
@@ -15,7 +15,11 @@ final class LibraryMetadataTest extends TestCase
$daten = json_decode($inhalt, true, 512, JSON_THROW_ON_ERROR); $daten = json_decode($inhalt, true, 512, JSON_THROW_ON_ERROR);
self::assertSame('Enelix Utils', $daten['name']); self::assertSame('Enelix Utils', $daten['name']);
self::assertSame('Enelix', $daten['author']);
self::assertSame('8.0', $daten['compatibility']['version']); self::assertSame('8.0', $daten['compatibility']['version']);
self::assertSame('0.1', $daten['version']);
self::assertSame(1, $daten['build']);
self::assertGreaterThan(0, $daten['date']);
self::assertMatchesRegularExpression('/^\{[0-9A-F-]{36}\}$/', $daten['id']); self::assertMatchesRegularExpression('/^\{[0-9A-F-]{36}\}$/', $daten['id']);
} }
+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); declare(strict_types=1);
return [ return [
'CC100Hardware' => [
'file' => __DIR__ . '/modules/CC100Hardware.php',
'paths' => [
'CC100Hardware/*',
],
],
'Energiediagramm' => [ 'Energiediagramm' => [
'file' => __DIR__ . '/modules/Energiediagramm.php', 'file' => __DIR__ . '/modules/Energiediagramm.php',
'paths' => [ 'paths' => [
'Energiediagramm/*', 'Energiediagramm/*',
], ],
], ],
'VGTSchnittstelle' => [
'file' => __DIR__ . '/modules/VGTSchnittstelle.php',
'paths' => [
'VGTSchnittstelle/*',
],
],
'VirtuelleBatterie' => [
'file' => __DIR__ . '/modules/VirtuelleBatterie.php',
'paths' => [
'VirtuelleBatterie/*',
],
],
'ShellyModul' => [ 'ShellyModul' => [
'file' => __DIR__ . '/modules/ShellyModul.php', 'file' => __DIR__ . '/modules/ShellyModul.php',
'paths' => [ '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 public function testAffectedModuleResolverSelectsAllModulesForFrameworkChanges(): void
{ {
self::assertSame( self::assertSame(
['Energiediagramm', 'ShellyModul', 'Verbrauchskostenreport'], [
'CC100Hardware',
'Energiediagramm',
'ShellyModul',
'VGTSchnittstelle',
'Verbrauchskostenreport',
'VirtuelleBatterie',
],
$this->resolveAffectedModules(['tests/Symcon/TestContext.php']) $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,
];
}
}