Sofar auslesen Modul hinzugefühgt.
This commit is contained in:
@@ -0,0 +1,105 @@
|
|||||||
|
2. Starte IP-Symcon neu bzw. klicke in der Konsole auf „Module aktualisieren“.
|
||||||
|
|
||||||
|
Anschließend erscheint in der Instanzliste der neue Gerätetyp **Sofar Wechselrichter**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Konfiguration
|
||||||
|
|
||||||
|
1. **Instanz anlegen**
|
||||||
|
- Gehe in der IPS-Konsole auf „Instanzen hinzufügen“ → Hersteller: (falls sichtbar) → Modul: **Sofar Wechselrichter**.
|
||||||
|
- Vergib einen sinnvollen Namen und eine Beschreibung.
|
||||||
|
|
||||||
|
2. **Einstellungen in den Eigenschaften**
|
||||||
|
- **Logger-Seriennummer**:
|
||||||
|
Gib hier die „Logger-Nummer“ deines Sofar-Wechselrichters ein (Dezimal).
|
||||||
|
- **Abfragezyklus (Sekunden)**:
|
||||||
|
Intervall in Sekunden, in dem zyklisch alle eingetragenen Register abgefragt werden.
|
||||||
|
Ein Wert von `0` deaktiviert den Timer (keine zyklischen Abfragen).
|
||||||
|
- **Register-Tabelle**:
|
||||||
|
Hier definierst du beliebig viele Zeilen, jeweils mit:
|
||||||
|
1. **Register-Nummer** (dezimal)
|
||||||
|
2. **Bezeichnung** (z. B. „Gesamtproduktion“ oder „Spannung Phase L1“)
|
||||||
|
3. **Skalierungs-faktor** (z. B. `0.1`, `1`, `10` usw.)
|
||||||
|
|
||||||
|
Wird beispielsweise für Register `1476` als Bezeichnung „Gesamtproduktion“ mit Skalierungsfaktor `1` eingetragen,
|
||||||
|
so liest das Modul alle 60 Sekunden (oder den von dir gewählten Zyklus) Register 1476,
|
||||||
|
multipliziert das rohe UINT16-Ergebnis mit `1` und legt den Wert in einer IPS-Variable an.
|
||||||
|
|
||||||
|
3. **Speichern/Übernehmen**
|
||||||
|
Klicke auf „Übernehmen“, um die Änderungen zu übernehmen.
|
||||||
|
Das Modul legt automatisch untergeordnete IPS-Variablen an:
|
||||||
|
|
||||||
|
- **Vorheriger Wert (Register 1160)**
|
||||||
|
→ INT16BE, unverändert (analog zur alten Node-RED-Logik).
|
||||||
|
Diese Variable heißt intern `Vorheriger Wert` und wird automatisch gepflegt.
|
||||||
|
- **Alle weiteren Einträge aus der Register-Tabelle**
|
||||||
|
→ Für jede Zeile wird eine Float-Variable mit der von dir angegebenen „Bezeichnung“ angelegt.
|
||||||
|
Der Variablen-Identifier lautet automatisch `Reg<Registernummer>` (z. B. `Reg1476`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Funktionsweise
|
||||||
|
|
||||||
|
1. **Initialisierung (ApplyChanges)**
|
||||||
|
- Liest den Abfragezyklus aus den Moduleigenschaften und initialisiert den Timer.
|
||||||
|
- Legt eine Integer-Variable `Vorheriger Wert` (Reg 1160) an.
|
||||||
|
- Legt für jede Zeile in der Register-Tabelle eine Float-Variable an (Ident `Reg<Nummer>`).
|
||||||
|
|
||||||
|
2. **Zyklische Abfrage (Timer-Callback)**
|
||||||
|
- **Register 1160 (INT16BE)**
|
||||||
|
→ Wird als „Vorheriger Wert“ in die Variable geschrieben (signed interpretiert).
|
||||||
|
- **Alle weiteren Register aus der Tabelle**
|
||||||
|
→ Jedes Register wird per Modbus-ähnlichem TCP-Paketaustausch abgefragt, als UINT16 ausgelesen,
|
||||||
|
mit dem angegebenen Skalierungsfaktor multipliziert und in der zugehörigen Float-Variable gespeichert.
|
||||||
|
|
||||||
|
3. **Kommunikation**
|
||||||
|
- TCP-Verbindung zu `192.168.0.0:8899` (feste IP im Code).
|
||||||
|
- Der Aufruf von `readRegister()` baut ein „Out_Frame“ wie in Node-RED,
|
||||||
|
rechnet CRC16-Modbus über die letzten 6 Bytes, hängt eine Summen-Checksum + 0x15 an,
|
||||||
|
sendet das Paket, liest die Antwort, schneidet exakt 2 Daten-Bytes heraus und liefert sie zurück.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Beispiel: Register 1476 („Gesamtproduktion“)
|
||||||
|
|
||||||
|
- **Register-Tabelle**
|
||||||
|
| Register‐Nummer | Bezeichnung | Skalierungsfaktor |
|
||||||
|
| --------------: | :------------------ | ----------------: |
|
||||||
|
| 1476 | Gesamtproduktion | 1 |
|
||||||
|
|
||||||
|
- **Ergebnis**
|
||||||
|
- Eine Float-Variable mit der Bezeichnung „Gesamtproduktion“ wird angelegt.
|
||||||
|
- Wenn der PollInterval auf `60` Sekunden steht, liest das Modul alle 60 Sekunden das Register 1476,
|
||||||
|
skaliert mit 1 und schreibt den numerischen Wert in `Reg1476`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Fehlersuche
|
||||||
|
|
||||||
|
- Falls die Variable „Vorheriger Wert“ immer denselben Wert liefert oder ein Lesefehler auftritt, prüfe bitte:
|
||||||
|
1. **Logger-Nummer**: Ist sie korrekt (Dezimal)?
|
||||||
|
2. **Netzwerk/Firewall**: Kann Symcon die Adresse `192.168.0.100:8899` erreichen?
|
||||||
|
3. **Debug-Ausgaben**:
|
||||||
|
– Öffne in der Konsole „Kernel-Log“ → Filter „SofarWechselrichter“.
|
||||||
|
– Dort werden WARNs und ERRs protokolliert, falls z. B. keine Antwort kommt oder das Datenpaket inkorrekt ist.
|
||||||
|
|
||||||
|
- Falls du andere Datentypen brauchst (z. B. INT16 für Register außerhalb 1160), definiere sie analog als separate Zeile:
|
||||||
|
– Trage die `Register‐Nummer` ein, gib als Skalierungsfaktor `1` (oder `0.1` etc.) an.
|
||||||
|
– Der absolute Rohwert wird stets als UINT16 interpretiert (0–65535).
|
||||||
|
– Solltest du negative INT16 benötigen, kannst du nachträglich einfach die Variable „Vorheriger Wert“ (Reg 1160)
|
||||||
|
als Beispiel nehmen und in einem Script umrechnen (Werte über 32767 → –32768 + Rest).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Versionshistorie
|
||||||
|
|
||||||
|
- **1.0**
|
||||||
|
- Erstveröffentlichung:
|
||||||
|
• Zyklische Abfrage beliebiger Register in einer Matrix konfigurieren
|
||||||
|
• Automatische Anlage von Variablen für jeden Eintrag
|
||||||
|
• Spezieller „Vorheriger Wert“ (Register 1160 als INT16)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Ende der Dokumentation.*
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
{
|
||||||
|
"elements": [
|
||||||
|
{
|
||||||
|
"type": "Label",
|
||||||
|
"caption": "Sofar Wechselrichter – Konfiguration"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "NumberSpinner",
|
||||||
|
"name": "LoggerNumber",
|
||||||
|
"caption": "Logger-Seriennummer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "NumberSpinner",
|
||||||
|
"name": "PollInterval",
|
||||||
|
"caption": "Abfragezyklus (Sekunden)",
|
||||||
|
"minimum": 1,
|
||||||
|
"suffix": "s"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "Matrix",
|
||||||
|
"name": "Registers",
|
||||||
|
"caption": "Register-Tabelle",
|
||||||
|
"add": "Registriere neuen Eintrag",
|
||||||
|
"delete": "Lösche Eintrag",
|
||||||
|
"type": [
|
||||||
|
{
|
||||||
|
"caption": "Register-Nummer",
|
||||||
|
"name": "RegisterNumber",
|
||||||
|
"width": "100px",
|
||||||
|
"edit": {
|
||||||
|
"type": "NumberSpinner",
|
||||||
|
"minimum": 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"caption": "Bezeichnung",
|
||||||
|
"name": "Label",
|
||||||
|
"width": "200px",
|
||||||
|
"edit": {
|
||||||
|
"type": "ValidationTextBox"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"caption": "Skalierungs-faktor",
|
||||||
|
"name": "ScalingFactor",
|
||||||
|
"width": "100px",
|
||||||
|
"edit": {
|
||||||
|
"type": "NumberSpinner",
|
||||||
|
"digits": 4,
|
||||||
|
"minimum": 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"actions": []
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"id": "{A1B2C3D4-EF56-7890-ABCD-1234567890EF}",
|
||||||
|
"type": 3,
|
||||||
|
"vendor": "Belevo AG",
|
||||||
|
"aliases": [],
|
||||||
|
"parentRequirements": [],
|
||||||
|
"childRequirements": [],
|
||||||
|
"implemented": [],
|
||||||
|
"prefix": "GEF",
|
||||||
|
"url": ""
|
||||||
|
}
|
||||||
@@ -0,0 +1,240 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
class SofarWechselrichter extends IPSModule
|
||||||
|
{
|
||||||
|
public function Create()
|
||||||
|
{
|
||||||
|
// Muss immer zuerst aufgerufen werden
|
||||||
|
parent::Create();
|
||||||
|
|
||||||
|
// Eigenschaften (Properties) anlegen
|
||||||
|
$this->RegisterPropertyInteger('LoggerNumber', 0);
|
||||||
|
$this->RegisterPropertyInteger('PollInterval', 60); // Standard 60 Sekunden
|
||||||
|
$this->RegisterPropertyString('Registers', '[]'); // JSON-String für Matrix
|
||||||
|
|
||||||
|
// Timer für zyklisches Abfragen registrieren (wird in ApplyChanges aktiviert)
|
||||||
|
$this->RegisterTimer('QueryTimer', 0, 'Sofar_Query($_IPS["TARGET"]);');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function ApplyChanges()
|
||||||
|
{
|
||||||
|
parent::ApplyChanges();
|
||||||
|
|
||||||
|
// PollInterval in Millisekunden umrechnen
|
||||||
|
$intervalSec = $this->ReadPropertyInteger('PollInterval');
|
||||||
|
$intervalMs = ($intervalSec > 0) ? $intervalSec * 1000 : 0;
|
||||||
|
$this->SetTimerInterval('QueryTimer', $intervalMs);
|
||||||
|
|
||||||
|
// Variablen für "Vorheriger Wert" (Register 1160) registrieren
|
||||||
|
$this->RegisterVariableInteger('PrevValue1160', 'Vorheriger Wert', '', 10);
|
||||||
|
|
||||||
|
// Aus der Property "Registers" JSON-String holen und in Array Decodieren
|
||||||
|
$registers = json_decode($this->ReadPropertyString('Registers'), true);
|
||||||
|
if (!is_array($registers)) {
|
||||||
|
$registers = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Für jeden definierten Register-Eintrag: Variable anlegen, falls nicht vorhanden
|
||||||
|
$position = 20;
|
||||||
|
foreach ($registers as $entry) {
|
||||||
|
$regNo = (int) $entry['RegisterNumber'];
|
||||||
|
$label = trim($entry['Label']);
|
||||||
|
// Ident darf keine Sonderzeichen/Leerzeichen enthalten – wir verwenden "Reg<RegNummer>"
|
||||||
|
$ident = 'Reg' . $regNo;
|
||||||
|
|
||||||
|
// Falls Bezeichnung leer oder Duplicate, überspringen
|
||||||
|
if ($regNo < 0 || $label === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Existiert die Variable noch nicht? Dann anlegen
|
||||||
|
if (!IPS_VariableExists(@IPS_GetObjectIDByIdent($ident, $this->InstanceID))) {
|
||||||
|
$this->RegisterVariableFloat($ident, $label, '', $position);
|
||||||
|
}
|
||||||
|
$position += 10;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Timer-Callback: liest zyklisch alle definierten Register.
|
||||||
|
*/
|
||||||
|
public function Query(): void
|
||||||
|
{
|
||||||
|
$loggerNumber = $this->ReadPropertyInteger('LoggerNumber');
|
||||||
|
if ($loggerNumber <= 0) {
|
||||||
|
// Ohne gültige Seriennummer bringt eine Abfrage keinen Sinn
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1) Vorherigen Wert (Register 1160) als INT16BE auslesen
|
||||||
|
try {
|
||||||
|
$bytes1160 = $this->readRegister($loggerNumber, 1160);
|
||||||
|
// 'n' = unsigned 16-bit big endian
|
||||||
|
$arr1160 = unpack('nvalue', $bytes1160);
|
||||||
|
$raw1160 = $arr1160['value'];
|
||||||
|
// Vorzeichenkorrektur für INT16
|
||||||
|
if ($raw1160 & 0x8000) {
|
||||||
|
$raw1160 -= 0x10000;
|
||||||
|
}
|
||||||
|
// Speichere in Variable "PrevValue1160"
|
||||||
|
SetValue($this->GetIDForIdent('PrevValue1160'), $raw1160);
|
||||||
|
} catch (Exception $e) {
|
||||||
|
// Im Fehlerfall einfach in die Debug-Konsole schreiben
|
||||||
|
$this->LogMessage('Fehler beim Lesen von Register 1160: ' . $e->getMessage(), KL_WARNING);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) Tabelle auslesen und alle Einträge abfragen
|
||||||
|
$registers = json_decode($this->ReadPropertyString('Registers'), true);
|
||||||
|
if (!is_array($registers)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($registers as $entry) {
|
||||||
|
$regNo = (int) $entry['RegisterNumber'];
|
||||||
|
$label = trim($entry['Label']);
|
||||||
|
$scale = (float) $entry['ScalingFactor'];
|
||||||
|
$ident = 'Reg' . $regNo;
|
||||||
|
|
||||||
|
if ($regNo < 0 || $label === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$bytes = $this->readRegister($loggerNumber, $regNo);
|
||||||
|
// 'n' = unsigned 16-bit big endian
|
||||||
|
$arr = unpack('nvalue', $bytes);
|
||||||
|
$valueRaw = $arr['value'];
|
||||||
|
// Skaliere
|
||||||
|
$valueScaled = $valueRaw * $scale;
|
||||||
|
// Schreibe in die korrespondierende Float-Variable
|
||||||
|
SetValue($this->GetIDForIdent($ident), $valueScaled);
|
||||||
|
} catch (Exception $e) {
|
||||||
|
$this->LogMessage("Fehler beim Lesen von Register {$regNo}: " . $e->getMessage(), KL_WARNING);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Führt eine Modbus-ähnliche Abfrage an Sofar durch und gibt genau 2 Bytes zurück.
|
||||||
|
*
|
||||||
|
* @param int $serial_nr Seriennummer (Logger-Nummer)
|
||||||
|
* @param int $reg Register-Adresse (dezimal, ein einziges Register)
|
||||||
|
* @return string Binäre Zeichenkette mit genau 2 Bytes (Registerinhalt)
|
||||||
|
* @throws Exception Bei jeglichem Kommunikationsfehler
|
||||||
|
*/
|
||||||
|
private function readRegister(int $serial_nr, int $reg): string
|
||||||
|
{
|
||||||
|
// 1) Out_Frame ohne CRC erstellen
|
||||||
|
$oFrame = 'a5170010450000';
|
||||||
|
|
||||||
|
// Seriennummer in 8-stelliges Hex (jekill Byte im Little-Endian)
|
||||||
|
$hexSN = str_pad(dechex($serial_nr), 8, '0', STR_PAD_LEFT);
|
||||||
|
$hexSNbytes = [
|
||||||
|
substr($hexSN, 6, 2),
|
||||||
|
substr($hexSN, 4, 2),
|
||||||
|
substr($hexSN, 2, 2),
|
||||||
|
substr($hexSN, 0, 2),
|
||||||
|
];
|
||||||
|
$oFrame .= implode('', $hexSNbytes);
|
||||||
|
|
||||||
|
// Data-Field (16 Hex-Zeichen konstant)
|
||||||
|
$oFrame .= '020000000000000000000000000000';
|
||||||
|
|
||||||
|
// Business-Field: 01 03 + Start-Register (2 Bytes) + Anzahl Register (2 Bytes = 1)
|
||||||
|
$startHex = str_pad(dechex($reg), 4, '0', STR_PAD_LEFT);
|
||||||
|
$numRegs = 1;
|
||||||
|
$numHex = str_pad(dechex($numRegs), 4, '0', STR_PAD_LEFT);
|
||||||
|
$oFrame .= '0103' . $startHex . $numHex;
|
||||||
|
|
||||||
|
// 2) CRC16-Modbus über die letzten 6 Bytes (12 Hex-Zeichen)
|
||||||
|
$crcInputHex = substr($oFrame, -12);
|
||||||
|
|
||||||
|
// Hilfsfunktion: CRC16-Modbus (Init=0xFFFF, Polynom=0xA001)
|
||||||
|
$crc16 = function(string $bin): int {
|
||||||
|
$crc = 0xFFFF;
|
||||||
|
$len = strlen($bin);
|
||||||
|
for ($pos = 0; $pos < $len; $pos++) {
|
||||||
|
$crc ^= ord($bin[$pos]);
|
||||||
|
for ($i = 0; $i < 8; $i++) {
|
||||||
|
if (($crc & 0x0001) !== 0) {
|
||||||
|
$crc >>= 1;
|
||||||
|
$crc ^= 0xA001;
|
||||||
|
} else {
|
||||||
|
$crc >>= 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $crc;
|
||||||
|
};
|
||||||
|
|
||||||
|
$crcInputBin = hex2bin($crcInputHex);
|
||||||
|
if ($crcInputBin === false) {
|
||||||
|
throw new Exception("Ungültiges Hex in CRC-Input: {$crcInputHex}");
|
||||||
|
}
|
||||||
|
|
||||||
|
$crcValue = $crc16($crcInputBin);
|
||||||
|
$crcHex = strtoupper(str_pad(dechex($crcValue), 4, '0', STR_PAD_LEFT));
|
||||||
|
$crcSwapped = substr($crcHex, 2, 2) . substr($crcHex, 0, 2);
|
||||||
|
$oFrameWithCRC = $oFrame . strtolower($crcSwapped);
|
||||||
|
|
||||||
|
// 3) Summen-Checksum über alle Bytes ab Index 1 + End-Byte 0x15
|
||||||
|
$l = strlen($oFrameWithCRC) / 2;
|
||||||
|
$bArr = [];
|
||||||
|
for ($i = 0; $i < $l; $i++) {
|
||||||
|
$byteHex = substr($oFrameWithCRC, 2 * $i, 2);
|
||||||
|
$bArr[$i] = hexdec($byteHex);
|
||||||
|
}
|
||||||
|
$crcSum = 0;
|
||||||
|
for ($i = 1; $i < $l; $i++) {
|
||||||
|
$crcSum += $bArr[$i];
|
||||||
|
$crcSum &= 0xFF;
|
||||||
|
}
|
||||||
|
$bArr[$l] = $crcSum;
|
||||||
|
$bArr[$l+1] = 0x15;
|
||||||
|
|
||||||
|
$frameBin = '';
|
||||||
|
foreach ($bArr as $b) {
|
||||||
|
$frameBin .= chr($b);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4) TCP-Verbindung öffnen, Paket senden und Antwort einlesen
|
||||||
|
$ip = '172.31.70.80';
|
||||||
|
$port = 8899;
|
||||||
|
$timeout = 5;
|
||||||
|
$fp = @stream_socket_client("tcp://{$ip}:{$port}", $errno, $errstr, $timeout);
|
||||||
|
if (!$fp) {
|
||||||
|
throw new Exception("Verbindung zu {$ip}:{$port} fehlgeschlagen ({$errno}: {$errstr})");
|
||||||
|
}
|
||||||
|
fwrite($fp, $frameBin);
|
||||||
|
|
||||||
|
stream_set_timeout($fp, 2);
|
||||||
|
$response = '';
|
||||||
|
while (!feof($fp)) {
|
||||||
|
$chunk = fread($fp, 1024);
|
||||||
|
if ($chunk === false || $chunk === '') {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
$response .= $chunk;
|
||||||
|
}
|
||||||
|
fclose($fp);
|
||||||
|
|
||||||
|
if ($response === '') {
|
||||||
|
throw new Exception("Keine Antwort vom Inverter.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5) Slice: Node-RED: l = 2*(reg–reg)+6 = 6. Dann slice(-l, -4) → (l–4) = 2 Bytes
|
||||||
|
$lModbus = 2 * ($reg - $reg) + 6; // = 6
|
||||||
|
$numBytes = $lModbus - 4; // = 2
|
||||||
|
if (strlen($response) < $lModbus) {
|
||||||
|
throw new Exception("Unerwartet kurze Antwort (< {$lModbus} Bytes)");
|
||||||
|
}
|
||||||
|
// Start vom Ende: -(lModbus), Länge = 2
|
||||||
|
$dataBytes = substr($response, -$lModbus, $numBytes);
|
||||||
|
if (strlen($dataBytes) < 2) {
|
||||||
|
throw new Exception("Data-Segment < 2 Bytes.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return $dataBytes;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user