247 lines
8.0 KiB
PHP
247 lines
8.0 KiB
PHP
<?php
|
||
declare(strict_types=1);
|
||
|
||
class SofarWechselrichter extends IPSModule
|
||
{
|
||
public function Create()
|
||
{
|
||
parent::Create();
|
||
// Moduleigenschaften
|
||
$this->RegisterPropertyString('IPAddress', '172.31.70.80');
|
||
$this->RegisterPropertyInteger('LoggerNumber', 0);
|
||
$this->RegisterPropertyInteger('PollInterval', 60);
|
||
$this->RegisterPropertyString('Registers', '[]'); // JSON-String
|
||
|
||
// Timer für zyklische Abfragen; ruft per RequestAction ident "Query" auf
|
||
$script = 'IPS_RequestAction(' . $this->InstanceID . ', "Query", "");';
|
||
$this->RegisterTimer('QueryTimer', 0, $script);
|
||
}
|
||
|
||
public function ApplyChanges()
|
||
{
|
||
parent::ApplyChanges();
|
||
// Timer-Intervall (ms) setzen
|
||
$intervalSec = $this->ReadPropertyInteger('PollInterval');
|
||
$intervalMs = ($intervalSec > 0) ? $intervalSec * 1000 : 0;
|
||
$this->SetTimerInterval('QueryTimer', $intervalMs);
|
||
|
||
// Variablen für jeden definierten Register-Eintrag anlegen
|
||
$registers = json_decode($this->ReadPropertyString('Registers'), true);
|
||
if (!is_array($registers)) {
|
||
$registers = [];
|
||
}
|
||
|
||
$position = 10;
|
||
foreach ($registers as $entry) {
|
||
$regNo = (int) $entry['RegisterNumber'];
|
||
$label = trim($entry['Label']);
|
||
if ($regNo < 0 || $label === '') {
|
||
continue;
|
||
}
|
||
$ident = 'Reg' . $regNo;
|
||
if (!IPS_VariableExists(@IPS_GetObjectIDByIdent($ident, $this->InstanceID))) {
|
||
$this->RegisterVariableFloat($ident, $label, '', $position);
|
||
}
|
||
$position += 10;
|
||
}
|
||
}
|
||
|
||
public function Destroy()
|
||
{
|
||
parent::Destroy();
|
||
}
|
||
|
||
/**
|
||
* Wird aufgerufen durch IPS_RequestAction über Timer
|
||
*/
|
||
public function RequestAction($Ident, $Value)
|
||
{
|
||
switch ($Ident) {
|
||
case 'Query':
|
||
$this->Query();
|
||
break;
|
||
|
||
default:
|
||
throw new Exception('Ungültiger Ident: ' . $Ident);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Führt eine Abfrage aller in der Tabelle definierten Register durch
|
||
*/
|
||
private function Query(): void
|
||
{
|
||
$ip = $this->ReadPropertyString('IPAddress');
|
||
$loggerNumber = $this->ReadPropertyInteger('LoggerNumber');
|
||
|
||
if ($loggerNumber <= 0 || filter_var($ip, FILTER_VALIDATE_IP) === false) {
|
||
$this->LogMessage(
|
||
'Abbruch: LoggerNumber = ' . $loggerNumber . ', IP = "' . $ip . '"',
|
||
KL_WARNING
|
||
);
|
||
return;
|
||
}
|
||
|
||
$registers = json_decode($this->ReadPropertyString('Registers'), true);
|
||
if (!is_array($registers) || count($registers) === 0) {
|
||
// Keine Register definiert
|
||
return;
|
||
}
|
||
|
||
foreach ($registers as $entry) {
|
||
$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($ip, $loggerNumber, $regNo, $endian);
|
||
if ($endian === 'LE') {
|
||
$arr = unpack('vvalue', $bytes); // UINT16 LE
|
||
} else {
|
||
$arr = unpack('nvalue', $bytes); // UINT16 BE
|
||
}
|
||
$valueRaw = $arr['value'];
|
||
$value = $valueRaw * $scale;
|
||
SetValue($this->GetIDForIdent($ident), $value);
|
||
} catch (Exception $e) {
|
||
$this->LogMessage("Fehler Lesen Reg {$regNo}: " . $e->getMessage(), KL_WARNING);
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Liest ein einzelnes Register per Modbus-ähnlichem TCP (2 Bytes zurück)
|
||
*
|
||
* @param string $ip Inverter-IP
|
||
* @param int $serial_nr Logger-Seriennummer
|
||
* @param int $reg Register-Adresse
|
||
* @param string $endian 'BE' oder 'LE'
|
||
* @return string 2 Byte binär
|
||
* @throws Exception Bei Kommunikationsfehler
|
||
*/
|
||
private function readRegister(
|
||
string $ip,
|
||
int $serial_nr,
|
||
int $reg,
|
||
string $endian
|
||
): string
|
||
{
|
||
// 1) Out_Frame ohne CRC aufbauen
|
||
$oFrame = 'a5170010450000';
|
||
$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);
|
||
$oFrame .= '020000000000000000000000000000';
|
||
$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 (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.");
|
||
}
|
||
|
||
// 6) Slice-Logik: l = 2*(reg–reg)+6 = 6 ⇒ 2 Bytes Daten
|
||
$lModbus = 6;
|
||
$numBytes = 2;
|
||
if (strlen($response) < $lModbus) {
|
||
throw new Exception("Unerwartet kurze Antwort (< {$lModbus} Bytes).");
|
||
}
|
||
$dataBytes = substr($response, -$lModbus, $numBytes);
|
||
if (strlen($dataBytes) < 2) {
|
||
throw new Exception("Data-Segment enthält weniger als 2 Bytes.");
|
||
}
|
||
return $dataBytes;
|
||
}
|
||
|
||
/**
|
||
* CRC16-Modbus (Init=0xFFFF, Polynom=0xA001)
|
||
*/
|
||
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));
|
||
}
|
||
}
|