Compare commits
7
Commits
db704dd011
...
beta
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c5f924bd19 | ||
|
|
82860c0eb9 | ||
|
|
59eec738ab | ||
|
|
40c6a17dd1 | ||
|
|
1f945be8f1 | ||
|
|
f11b1fbe61 | ||
|
|
92eec804a7 |
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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" }
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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": ""
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
Alle wesentlichen Aenderungen an Enelix Utils werden in dieser Datei dokumentiert.
|
||||||
|
|
||||||
|
## 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.
|
||||||
@@ -24,14 +24,25 @@ 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. Hintergrund, Text und
|
||||||
die Darstellung aktualisiert. Das Diagrammraster wechselt bei geringer Breite
|
Bedienelemente verwenden die von IP-Symcon bereitgestellten Kachel- und
|
||||||
automatisch von zwei Spalten auf eine Spalte.
|
Textfarben. Auch geoeffnete Auswahlfelder sowie native Datumselemente passen sich
|
||||||
|
automatisch an ein helles oder dunkles Visualisierungsthema an. 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
|
||||||
|
|
||||||
|
|||||||
@@ -118,9 +118,19 @@ final class EnergyChartCalculator
|
|||||||
|
|
||||||
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
|
||||||
|
|||||||
@@ -65,17 +65,19 @@
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
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 {
|
||||||
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
.toolbar,
|
.toolbar,
|
||||||
@@ -104,11 +106,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;
|
||||||
@@ -234,7 +241,7 @@
|
|||||||
|
|
||||||
@media (max-width: 560px) {
|
@media (max-width: 560px) {
|
||||||
#energy-app {
|
#energy-app {
|
||||||
padding: 10px;
|
padding: 58px 10px 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.toolbar,
|
.toolbar,
|
||||||
@@ -277,6 +284,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;
|
||||||
@@ -372,6 +396,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 || '';
|
||||||
|
|||||||
@@ -37,7 +37,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,
|
||||||
|
|||||||
@@ -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`.
|
||||||
|
|||||||
@@ -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).
|
||||||
@@ -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);"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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": ""
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 |
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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'));
|
||||||
|
|
||||||
|
|||||||
@@ -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).
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"id": "{5B2A199B-2226-411F-B83B-FC1CBA58496B}",
|
||||||
|
"name": "Virtuelle Batterie",
|
||||||
|
"type": 3,
|
||||||
|
"vendor": "Enelix",
|
||||||
|
"aliases": [],
|
||||||
|
"parentRequirements": [],
|
||||||
|
"childRequirements": [],
|
||||||
|
"implemented": [],
|
||||||
|
"prefix": "ENLVB",
|
||||||
|
"url": ""
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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. |
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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'],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,11 +49,16 @@ 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 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 +66,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);
|
||||||
|
|||||||
@@ -110,9 +110,21 @@ 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')
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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']);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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' => [
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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));
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -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'])
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user