Files
Symcon_Belevo_Energiemanage…/SofarWechselrichter/module.php
2025-06-04 11:19:03 +02:00

284 lines
9.4 KiB
PHP

<?php
declare(strict_types=1);
/**
* Sofar Wechselrichter Modul
*
* Anpassung: LoggerNumber als String behandeln, da Dezimalwert > PHP-Integer
*/
class SofarWechselrichter extends IPSModule
{
public function Create()
{
parent::Create();
// Moduleigenschaften
$this->RegisterPropertyString('IPAddress', '');
$this->RegisterPropertyString('LoggerNumber', '0'); // jetzt String
$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
{
$rawIP = $this->ReadPropertyString('IPAddress');
$ip = trim($rawIP);
$loggerNumberStr = trim($this->ReadPropertyString('LoggerNumber'));
// Validierungs-Check: IP und LoggerNumber
if (!filter_var($ip, FILTER_VALIDATE_IP)) {
$this->LogMessage('Abbruch: Ungültige IP = "' . $ip . '"', KL_WARNING);
return;
}
if ($loggerNumberStr === '' || !ctype_digit($loggerNumberStr) || bccomp($loggerNumberStr, '1') < 0) {
$this->LogMessage('Abbruch: Ungültige LoggerNumber = "' . $loggerNumberStr . '"', KL_WARNING);
return;
}
// Tabellen-Einträge holen
$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, $loggerNumberStr, $regNo);
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 string $serial_nr_str Logger-Seriennummer als Dezimal-String
* @param int $reg Register-Adresse
* @return string 2 Byte binär
* @throws Exception Bei Kommunikationsfehler
*/
private function readRegister(
string $ip,
string $serial_nr_str,
int $reg
): string
{
// 1) Out_Frame ohne CRC aufbauen
$oFrame = 'a5170010450000';
// Dekimal-String → 8-stellige Hex-String
$hexSN8 = $this->decStringToHex8($serial_nr_str);
// Byte-Little-Endian: je 2 Hex-Zeichen umkehren
$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 (2 Bytes) + Anzahl Register (2 Bytes = 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 (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 = 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;
}
/**
* Wandelt einen Dezimal-String in einen 8-stelligen Hex-String um (ohne Präfix).
*
* @param string $decString
* @return string 8-stellige Hexadezimal-Notation (Großbuchstaben)
*/
private function decStringToHex8(string $decString): string
{
// BCMath erforderlich. Dezimal-String iterativ in Hex umwandeln.
$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);
}
/**
* 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));
}
}