diff --git a/SofarWechselrichter/form.json b/SofarWechselrichter/form.json index fbe2987..ff73df2 100644 --- a/SofarWechselrichter/form.json +++ b/SofarWechselrichter/form.json @@ -4,10 +4,21 @@ "type": "Label", "caption": "Sofar Wechselrichter Konfiguration" }, + { + "type": "ValidationTextBox", + "name": "IPAddress", + "caption": "Inverter IP-Adresse", + "validate": { + "required": true, + "pattern": "^((25[0-5]|2[0-4]\\d|[01]?\\d?\\d)(\\.(?!$)|$)){4}$", + "errorMessage": "Bitte eine gültige IPv4-Adresse eingeben." + } + }, { "type": "NumberSpinner", "name": "LoggerNumber", - "caption": "Logger-Seriennummer" + "caption": "Logger-Seriennummer", + "minimum": 0 }, { "type": "NumberSpinner", @@ -20,7 +31,7 @@ "type": "List", "name": "Registers", "caption": "Register-Tabelle", - "add": "Registriere neuen Eintrag", + "add": "Neues Register hinzufügen", "delete": "Lösche Eintrag", "columns": [ { @@ -52,6 +63,19 @@ "digits": 4, "minimum": 0 } + }, + { + "caption": "Endian", + "name": "Endian", + "width": "80px", + "add": "BE", + "edit": { + "type": "Select", + "options": [ + { "caption": "BE", "value": "BE" }, + { "caption": "LE", "value": "LE" } + ] + } } ] } diff --git a/SofarWechselrichter/module.php b/SofarWechselrichter/module.php index 7172bcd..8c28087 100644 --- a/SofarWechselrichter/module.php +++ b/SofarWechselrichter/module.php @@ -5,15 +5,14 @@ class SofarWechselrichter extends IPSModule { public function Create() { - // Muss immer zuerst aufgerufen werden parent::Create(); - - // Eigenschaften (Properties) anlegen + // Moduleigenschaften + $this->RegisterPropertyString('IPAddress', '172.31.70.80'); $this->RegisterPropertyInteger('LoggerNumber', 0); - $this->RegisterPropertyInteger('PollInterval', 60); // Standard 60 Sekunden - $this->RegisterPropertyString('Registers', '[]'); // JSON-String für Matrix + $this->RegisterPropertyInteger('PollInterval', 60); + $this->RegisterPropertyString('Registers', '[]'); // JSON-String - // Timer für zyklisches Abfragen registrieren (wird in ApplyChanges aktiviert) + // Timer für zyklische Abfragen $this->RegisterTimer('QueryTimer', 0, 'Sofar_Query($_IPS["TARGET"]);'); } @@ -21,34 +20,31 @@ class SofarWechselrichter extends IPSModule { parent::ApplyChanges(); - // PollInterval in Millisekunden umrechnen + // Timer-Intervall (ms) setzen $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); + // Variable für vorherigen Wert (Register 1160) anlegen, falls noch nicht vorhanden + if (!$this->VariableExists('PrevValue1160')) { + $this->RegisterVariableInteger('PrevValue1160', 'Vorheriger Wert (Reg 1160)', '', 10); + } - // Aus der Property "Registers" JSON-String holen und in Array Decodieren + // Alle Einträge aus „Registers“-Liste aus Property lesen $registers = json_decode($this->ReadPropertyString('Registers'), true); if (!is_array($registers)) { $registers = []; } - // Für jeden definierten Register-Eintrag: Variable anlegen, falls nicht vorhanden + // Für jeden Eintrag: Variable anlegen, falls nicht existiert $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 + $ident = 'Reg' . $regNo; if (!IPS_VariableExists(@IPS_GetObjectIDByIdent($ident, $this->InstanceID))) { $this->RegisterVariableFloat($ident, $label, '', $position); } @@ -56,79 +52,93 @@ class SofarWechselrichter extends IPSModule } } + public function Destroy() + { + parent::Destroy(); + } + /** - * Timer-Callback: liest zyklisch alle definierten Register. + * Timer-Callback: wird in Intervallen aufgerufen */ public function Query(): void { + $ip = $this->ReadPropertyString('IPAddress'); $loggerNumber = $this->ReadPropertyInteger('LoggerNumber'); + + // Ohne gültige Seriennummer abbrechen if ($loggerNumber <= 0) { - // Ohne gültige Seriennummer bringt eine Abfrage keinen Sinn + $this->LogMessage('LoggerNumber ist ≤ 0, Abbruch', KL_WARNING); return; } - // 1) Vorherigen Wert (Register 1160) als INT16BE auslesen + // (1) Register 1160 als INT16BE auslesen → „Vorheriger Wert“ try { - $bytes1160 = $this->readRegister($loggerNumber, 1160); - // 'n' = unsigned 16-bit big endian - $arr1160 = unpack('nvalue', $bytes1160); - $raw1160 = $arr1160['value']; - // Vorzeichenkorrektur für INT16 + $bytes1160 = $this->readRegister($ip, $loggerNumber, 1160, 'BE'); + $arr1160 = unpack('nvalue', $bytes1160); + $raw1160 = $arr1160['value']; + // Vorzeichenkorrektur 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); + $this->LogMessage('Fehler Lesen Reg 1160: ' . $e->getMessage(), KL_WARNING); } - // 2) Tabelle auslesen und alle Einträge abfragen + // (2) Alle Einträge aus der Registerliste 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; + $regNo = (int) $entry['RegisterNumber']; + $label = trim((string)$entry['Label']); + $scale = (float) $entry['ScalingFactor']; + $endian = strtoupper(trim((string)$entry['Endian'])); + $ident = 'Reg' . $regNo; if ($regNo < 0 || $label === '') { continue; } try { - $bytes = $this->readRegister($loggerNumber, $regNo); - // 'n' = unsigned 16-bit big endian - $arr = unpack('nvalue', $bytes); + $bytes = $this->readRegister($ip, $loggerNumber, $regNo, $endian); + if ($endian === 'LE') { + $arr = unpack('vvalue', $bytes); // uint16 little-endian + } else { + $arr = unpack('nvalue', $bytes); // uint16 big-endian + } $valueRaw = $arr['value']; - // Skaliere - $valueScaled = $valueRaw * $scale; - // Schreibe in die korrespondierende Float-Variable - SetValue($this->GetIDForIdent($ident), $valueScaled); + $value = $valueRaw * $scale; + SetValue($this->GetIDForIdent($ident), $value); } catch (Exception $e) { - $this->LogMessage("Fehler beim Lesen von Register {$regNo}: " . $e->getMessage(), KL_WARNING); + $this->LogMessage("Fehler Lesen Reg {$regNo}: " . $e->getMessage(), KL_WARNING); } } } /** - * Führt eine Modbus-ähnliche Abfrage an Sofar durch und gibt genau 2 Bytes zurück. + * Liest genau 2 Bytes eines Registers (Modbus-ähnlich über TCP, Port fix 8899). * - * @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 + * @param string $ip IP-Adresse des Inverters + * @param int $serial_nr Seriennummer (Logger-Nummer) + * @param int $reg Register-Adresse + * @param string $endian 'BE' oder 'LE' + * @return string Binärkette mit genau 2 Bytes + * @throws Exception Bei Kommunikationsfehlern */ - private function readRegister(int $serial_nr, int $reg): string + private function readRegister( + string $ip, + int $serial_nr, + int $reg, + string $endian + ): string { - // 1) Out_Frame ohne CRC erstellen + // 1) Out_Frame ohne CRC aufbauen $oFrame = 'a5170010450000'; - // Seriennummer in 8-stelliges Hex (jekill Byte im Little-Endian) + // Seriennummer (8-stelliges Hex), Byte-Little-Endian pro Byte $hexSN = str_pad(dechex($serial_nr), 8, '0', STR_PAD_LEFT); $hexSNbytes = [ substr($hexSN, 6, 2), @@ -147,38 +157,19 @@ class SofarWechselrichter extends IPSModule $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) + // 2) CRC16-Modbus (letzte 6 Bytes) $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); + $crcValue = $this->calculateCRC16Modbus($crcInputBin); + $crcHex = strtoupper(str_pad(dechex($crcValue), 4, '0', STR_PAD_LEFT)); + // Byte-Swap (Low-Byte zuerst) + $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 + // 3) Summen-Checksum (alle Bytes ab Index 1) + 0x15 $l = strlen($oFrameWithCRC) / 2; $bArr = []; for ($i = 0; $i < $l; $i++) { @@ -198,17 +189,17 @@ class SofarWechselrichter extends IPSModule $frameBin .= chr($b); } - // 4) TCP-Verbindung öffnen, Paket senden und Antwort einlesen - $ip = '172.31.70.80'; + // 4) TCP-Verbindung öffnen & Paket senden (Port ist fest auf 8899) $port = 8899; - $timeout = 5; - $fp = @stream_socket_client("tcp://{$ip}:{$port}", $errno, $errstr, $timeout); + $timeoutSec = 5; + $fp = @stream_socket_client("tcp://{$ip}:{$port}", $errno, $errstr, $timeoutSec); if (!$fp) { throw new Exception("Verbindung zu {$ip}:{$port} fehlgeschlagen ({$errno}: {$errstr})"); } fwrite($fp, $frameBin); - stream_set_timeout($fp, 2); + + // 5) Antwort einlesen $response = ''; while (!feof($fp)) { $chunk = fread($fp, 1024); @@ -220,21 +211,56 @@ class SofarWechselrichter extends IPSModule fclose($fp); if ($response === '') { - throw new Exception("Keine Antwort vom Inverter."); + throw new Exception("Keine Antwort vom Inverter erhalten."); } - // 5) Slice: Node-RED: l = 2*(reg–reg)+6 = 6. Dann slice(-l, -4) → (l–4) = 2 Bytes + // 6) Slice-Logik: l = 2*(reg–reg)+6 = 6, dann slice(-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)"); + throw new Exception("Unerwartet kurze Antwort (weniger als {$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."); + throw new Exception("Data-Segment enthält weniger als 2 Bytes."); } return $dataBytes; } + + /** + * Berechnet CRC16-Modbus (Init=0xFFFF, Polynom=0xA001) über Binärdaten. + * + * @param string $binaryData + * @return int + */ + private function calculateCRC16Modbus(string $binaryData): int + { + $crc = 0xFFFF; + $len = strlen($binaryData); + for ($pos = 0; $pos < $len; $pos++) { + $crc ^= ord($binaryData[$pos]); + for ($i = 0; $i < 8; $i++) { + if (($crc & 0x0001) !== 0) { + $crc >>= 1; + $crc ^= 0xA001; + } else { + $crc >>= 1; + } + } + } + return $crc; + } + + /** + * Hilfsfunktion: Prüft, ob eine Variable mit gegebenem Ident existiert. + * + * @param string $ident + * @return bool + */ + private function VariableExists(string $ident): bool + { + $vid = @IPS_GetObjectIDByIdent($ident, $this->InstanceID); + return ($vid !== false && IPS_VariableExists($vid)); + } }