380 lines
14 KiB
PHP
380 lines
14 KiB
PHP
<?php
|
|
declare(strict_types=1);
|
|
|
|
/**
|
|
* Sofar Wechselrichter Modul (IP-Symcon)
|
|
*
|
|
* - LoggerNumber als String (kein Integer-Overflow).
|
|
* - Negative Skalierungen erlaubt.
|
|
* - Bit-Länge pro Register (16, 32, 64) auswählbar.
|
|
* - Signed/Unsigned pro Register wählbar.
|
|
* - Liest 32/64-Bit-Werte registerweise einzeln und setzt anschließend zusammen.
|
|
* - Gelöschte Register-Variablen werden entfernt.
|
|
* - Debug-Logs zeigen Raw-Response, um Slice-Position zu prüfen.
|
|
*/
|
|
class SofarWechselrichter extends IPSModule
|
|
{
|
|
public function Create()
|
|
{
|
|
parent::Create();
|
|
// Moduleigenschaften
|
|
$this->RegisterPropertyString('IPAddress', '');
|
|
$this->RegisterPropertyString('LoggerNumber', '0'); // als String
|
|
$this->RegisterPropertyInteger('PollInterval', 60);
|
|
$this->RegisterPropertyString('Registers', '[]'); // JSON-String
|
|
|
|
// Timer für zyklische Abfragen (per RequestAction("Query"))
|
|
$script = 'IPS_RequestAction(' . $this->InstanceID . ', "Query", "");';
|
|
$this->RegisterTimer('QueryTimer', 0, $script);
|
|
}
|
|
|
|
public function ApplyChanges()
|
|
{
|
|
parent::ApplyChanges();
|
|
// Timer-Intervall (ms)
|
|
$intervalSec = $this->ReadPropertyInteger('PollInterval');
|
|
$intervalMs = ($intervalSec > 0) ? $intervalSec * 1000 : 0;
|
|
$this->SetTimerInterval('QueryTimer', $intervalMs);
|
|
|
|
// Aktuelle Registerliste
|
|
$registers = json_decode($this->ReadPropertyString('Registers'), true);
|
|
if (!is_array($registers)) {
|
|
$registers = [];
|
|
}
|
|
|
|
// 1) Variablen anlegen (neu hinzugekommene Register)
|
|
$position = 10;
|
|
foreach ($registers as $entry) {
|
|
$regNo = (int) $entry['RegisterNumber'];
|
|
$label = trim($entry['Label']);
|
|
if ($regNo < 0 || $label === '') {
|
|
continue;
|
|
}
|
|
$ident = 'Reg' . $regNo;
|
|
if (!$this->VariableExists($ident)) {
|
|
$this->RegisterVariableFloat($ident, $label, '', $position);
|
|
}
|
|
$position += 10;
|
|
}
|
|
|
|
// 2) Variablen löschen (falls Register entfernt wurden)
|
|
$validIdents = [];
|
|
foreach ($registers as $entry) {
|
|
$validIdents[] = 'Reg' . ((int)$entry['RegisterNumber']);
|
|
}
|
|
$children = IPS_GetChildrenIDs($this->InstanceID);
|
|
foreach ($children as $childID) {
|
|
$obj = IPS_GetObject($childID);
|
|
if ($obj['ObjectType'] !== 2) {
|
|
continue;
|
|
}
|
|
$ident = $obj['ObjectIdent'];
|
|
if (substr($ident, 0, 3) === 'Reg') {
|
|
if (!in_array($ident, $validIdents)) {
|
|
IPS_DeleteVariable($childID);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
public function Destroy()
|
|
{
|
|
parent::Destroy();
|
|
}
|
|
|
|
/**
|
|
* Wird aufgerufen durch IPS_RequestAction über den Timer
|
|
*/
|
|
public function RequestAction($Ident, $Value)
|
|
{
|
|
switch ($Ident) {
|
|
case 'Query':
|
|
$this->Query();
|
|
break;
|
|
default:
|
|
throw new Exception('Ungültiger Ident: ' . $Ident);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Zyklische Abfrage aller definierten Register
|
|
*/
|
|
private function Query(): void
|
|
{
|
|
$this->LogMessage('Query invoked', KL_MESSAGE);
|
|
|
|
$ip = trim($this->ReadPropertyString('IPAddress'));
|
|
$loggerNumberStr = trim($this->ReadPropertyString('LoggerNumber'));
|
|
|
|
// 1) Validierung IP
|
|
if (!filter_var($ip, FILTER_VALIDATE_IP)) {
|
|
$this->LogMessage('Abbruch: Ungültige IP = "' . $ip . '"', KL_WARNING);
|
|
return;
|
|
}
|
|
// 2) Validierung LoggerNumber (Dezimal-String, > 0)
|
|
if ($loggerNumberStr === '' || !ctype_digit($loggerNumberStr) || bccomp($loggerNumberStr, '1') < 0) {
|
|
$this->LogMessage('Abbruch: Ungültige LoggerNumber = "' . $loggerNumberStr . '"', KL_WARNING);
|
|
return;
|
|
}
|
|
|
|
// 3) Register-Liste einlesen
|
|
$registers = json_decode($this->ReadPropertyString('Registers'), true);
|
|
if (!is_array($registers) || count($registers) === 0) {
|
|
// Keine Register definiert
|
|
return;
|
|
}
|
|
|
|
// 4) Für jedes Register: einzeln auslesen, zusammensetzen, skalieren, speichern
|
|
foreach ($registers as $entry) {
|
|
$regNo = (int) $entry['RegisterNumber'];
|
|
$label = trim((string)$entry['Label']);
|
|
$scale = (string) $entry['ScalingFactor']; // kann negativ sein
|
|
$endian = strtoupper(trim((string)$entry['Endian']));
|
|
$bitLength = (int) $entry['BitLength']; // 16, 32 oder 64
|
|
$signedness = trim((string)$entry['Signedness']); // "Signed" oder "Unsigned"
|
|
$ident = 'Reg' . $regNo;
|
|
|
|
if ($regNo < 0 || $label === '' || !in_array($bitLength, [16, 32, 64])) {
|
|
continue;
|
|
}
|
|
|
|
try {
|
|
$numRegs = $bitLength / 16; // 1, 2 oder 4
|
|
// Bytes registerweise einzeln abfragen und zusammenfügen:
|
|
$dataBytes = '';
|
|
for ($i = 0; $i < $numRegs; $i++) {
|
|
$chunk = $this->readSingleRegister($ip, $loggerNumberStr, $regNo + $i);
|
|
$dataBytes .= $chunk;
|
|
}
|
|
// Debug: raw combined response hex
|
|
$combinedHex = strtoupper(bin2hex($dataBytes));
|
|
$this->LogMessage("Raw data for Reg {$regNo} ({$bitLength}bit): {$combinedHex}", KL_MESSAGE);
|
|
|
|
// Endian-Handling: falls LE, kehre gesamte Byte-Reihenfolge um
|
|
if ($endian === 'LE') {
|
|
$combinedHex = $this->reverseByteOrder($combinedHex);
|
|
$this->LogMessage("After LE reverse: {$combinedHex}", KL_MESSAGE);
|
|
}
|
|
|
|
// Konvertiere Hex in Dezimal-String
|
|
$rawDec = $this->hexToDecimal($combinedHex);
|
|
|
|
// Bei "Signed" → Zwei-Komplement-Umrechnung
|
|
if ($signedness === 'Signed') {
|
|
$half = bcpow('2', (string)($bitLength - 1), 0); // 2^(bitLength-1)
|
|
$fullRange = bcpow('2', (string)$bitLength, 0); // 2^bitLength
|
|
if (bccomp($rawDec, $half) >= 0) {
|
|
$rawDec = bcsub($rawDec, $fullRange, 0);
|
|
}
|
|
$this->LogMessage("Signed rawDec for Reg {$regNo}: {$rawDec}", KL_MESSAGE);
|
|
}
|
|
|
|
// Skaliere (bc*-Multiplikation, 4 Nachkommastellen)
|
|
$valueStr = bcmul($rawDec, $scale, 4);
|
|
SetValueFloat($this->GetIDForIdent($ident), (float)$valueStr);
|
|
$this->LogMessage("Final value for Reg {$regNo}: {$valueStr}", KL_MESSAGE);
|
|
} catch (Exception $e) {
|
|
$this->LogMessage(
|
|
"Fehler Lesen Reg {$regNo} ({$bitLength}bit, {$signedness}): " . $e->getMessage(),
|
|
KL_WARNING
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Liest genau ein Register (16 Bit) per Modbus-ähnlichem TCP (2 Bytes zurück).
|
|
*
|
|
* @param string $ip Inverter-IP
|
|
* @param string $serial_nr_str Logger-Seriennummer als Dezimal-String
|
|
* @param int $reg Register-Adresse
|
|
* @return string 2-Byte-Binär-String
|
|
* @throws Exception Bei Kommunikationsfehlern
|
|
*/
|
|
private function readSingleRegister(string $ip, string $serial_nr_str, int $reg): string
|
|
{
|
|
// 1) Out_Frame ohne CRC aufbauen
|
|
$oFrame = 'a5170010450000';
|
|
|
|
// Dezimal-String → 8-stellige Hex
|
|
$hexSN8 = $this->decStringToHex8($serial_nr_str);
|
|
$hexSNbytes = [
|
|
substr($hexSN8, 6, 2),
|
|
substr($hexSN8, 4, 2),
|
|
substr($hexSN8, 2, 2),
|
|
substr($hexSN8, 0, 2),
|
|
];
|
|
$oFrame .= implode('', $hexSNbytes);
|
|
|
|
// Data-Field (16 Hex-Zeichen konstant)
|
|
$oFrame .= '020000000000000000000000000000';
|
|
|
|
// Business-Field: 01 03 + Start-Register + Anzahl Register (1)
|
|
$startHex = str_pad(dechex($reg), 4, '0', STR_PAD_LEFT);
|
|
$numHex = str_pad(dechex(1), 4, '0', STR_PAD_LEFT);
|
|
$oFrame .= '0103' . $startHex . $numHex;
|
|
|
|
// 2) CRC16-Modbus (letzte 6 Bytes)
|
|
$crcInputHex = substr($oFrame, -12);
|
|
$crcInputBin = hex2bin($crcInputHex);
|
|
if ($crcInputBin === false) {
|
|
throw new Exception("Ungültiges Hex in CRC-Input: {$crcInputHex}");
|
|
}
|
|
$crcValue = $this->calculateCRC16Modbus($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 (alle Bytes ab Index 1) + 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 (Port fest 8899)
|
|
$port = 8899;
|
|
$fp = @stream_socket_client("tcp://{$ip}:{$port}", $errno, $errstr, 5);
|
|
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);
|
|
if ($chunk === false || $chunk === '') {
|
|
break;
|
|
}
|
|
$response .= $chunk;
|
|
}
|
|
fclose($fp);
|
|
if ($response === '') {
|
|
throw new Exception("Keine Antwort vom Inverter erhalten.");
|
|
}
|
|
|
|
// Debug: log raw response hex
|
|
$respHex = strtoupper(bin2hex($response));
|
|
$this->LogMessage("Raw response for single reg {$reg}: {$respHex}", KL_MESSAGE);
|
|
|
|
// 6) Slice-Logik: l = 2*1 + 4 = 6, slice(-6, 2) → 2 Bytes
|
|
$lModbus = 2 * 1 + 4; // = 6
|
|
$numBytes = 2;
|
|
if (strlen($response) < $lModbus) {
|
|
throw new Exception("Unerwartet kurze Antwort (< {$lModbus} Bytes).");
|
|
}
|
|
$dataBytes = substr($response, -$lModbus, $numBytes);
|
|
if (strlen($dataBytes) < $numBytes) {
|
|
throw new Exception("Data-Segment enthält weniger als {$numBytes} Bytes.");
|
|
}
|
|
$dataHex = strtoupper(bin2hex($dataBytes));
|
|
$this->LogMessage("Sliced data for single reg {$reg}: {$dataHex}", KL_MESSAGE);
|
|
|
|
return $dataBytes;
|
|
}
|
|
|
|
/**
|
|
* Wandelt einen Dezimal-String in einen 8-stelligen Hex-String um.
|
|
*
|
|
* @param string $decString
|
|
* @return string 8-stellige Hex (uppercase)
|
|
*/
|
|
private function decStringToHex8(string $decString): string
|
|
{
|
|
$num = ltrim($decString, '0');
|
|
if ($num === '') {
|
|
return '00000000';
|
|
}
|
|
$hex = '';
|
|
while (bccomp($num, '0') > 0) {
|
|
$mod = bcmod($num, '16');
|
|
$digit = dechex((int)$mod);
|
|
$hex = strtoupper($digit) . $hex;
|
|
$num = bcdiv($num, '16', 0);
|
|
}
|
|
return str_pad($hex, 8, '0', STR_PAD_LEFT);
|
|
}
|
|
|
|
/**
|
|
* Kehrt die Byte-Reihenfolge eines Hex-Strings um (2 Hex-Zeichen = 1 Byte).
|
|
*
|
|
* @param string $hex Hex-Repr. (z.B. "A1B2C3D4")
|
|
* @return string Umgekehrte Byte-Reihenfolge (z.B. "D4C3B2A1")
|
|
*/
|
|
private function reverseByteOrder(string $hex): string
|
|
{
|
|
$bytes = str_split($hex, 2);
|
|
$bytes = array_reverse($bytes);
|
|
return implode('', $bytes);
|
|
}
|
|
|
|
/**
|
|
* Konvertiert einen Hex-String in einen Dezimal-String (BCMath).
|
|
*
|
|
* @param string $hex Uppercase-Hex ohne Präfix (z.B. "00FF10A3")
|
|
* @return string Dezimal-String (z.B. "16737763")
|
|
*/
|
|
private function hexToDecimal(string $hex): string
|
|
{
|
|
$hex = ltrim($hex, '0');
|
|
if ($hex === '') {
|
|
return '0';
|
|
}
|
|
$len = strlen($hex);
|
|
$dec = '0';
|
|
$power16 = '1';
|
|
for ($i = $len - 1; $i >= 0; $i--) {
|
|
$digit = hexdec($hex[$i]);
|
|
$term = bcmul((string)$digit, $power16, 0);
|
|
$dec = bcadd($dec, $term, 0);
|
|
$power16 = bcmul($power16, '16', 0);
|
|
}
|
|
return $dec;
|
|
}
|
|
|
|
/**
|
|
* Berechnet CRC16-Modbus (Init=0xFFFF, Poly=0xA001) über Binärdaten.
|
|
*/
|
|
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;
|
|
}
|
|
|
|
/**
|
|
* Prüft, ob eine Variable mit Ident existiert.
|
|
*/
|
|
private function VariableExists(string $ident): bool
|
|
{
|
|
$vid = @IPS_GetObjectIDByIdent($ident, $this->InstanceID);
|
|
return ($vid !== false && IPS_VariableExists($vid));
|
|
}
|
|
}
|