diff --git a/SofarWechselrichter/README.md b/SofarWechselrichter/README.md new file mode 100644 index 0000000..4fa144c --- /dev/null +++ b/SofarWechselrichter/README.md @@ -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` (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`). + +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.* diff --git a/SofarWechselrichter/form.json b/SofarWechselrichter/form.json new file mode 100644 index 0000000..c1fad0a --- /dev/null +++ b/SofarWechselrichter/form.json @@ -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": [] +} diff --git a/SofarWechselrichter/module.json b/SofarWechselrichter/module.json new file mode 100644 index 0000000..53f4f54 --- /dev/null +++ b/SofarWechselrichter/module.json @@ -0,0 +1,11 @@ +{ + "id": "{A1B2C3D4-EF56-7890-ABCD-1234567890EF}", + "type": 3, + "vendor": "Belevo AG", + "aliases": [], + "parentRequirements": [], + "childRequirements": [], + "implemented": [], + "prefix": "GEF", + "url": "" +} diff --git a/SofarWechselrichter/module.php b/SofarWechselrichter/module.php new file mode 100644 index 0000000..7172bcd --- /dev/null +++ b/SofarWechselrichter/module.php @@ -0,0 +1,240 @@ +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" + $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; + } +}