Ik wilde - net als Rene - met php de modbus interface van mijn twee SolarEdge inverters uitlezen. Flauw van hem om zijn kennis niet verder te delen!
In de SolarEdge documentatie "Technical Note – SunSpec Logging in SolarEdge Inverters, Appendix A" staat dat je een request als volgt stuurt (hexadecimaal):
Tx: 01 03 9C 40 00 7A EB AD – Read 122 registers starting at address 40001
Maar dan krijg je dus niks terug, zoals hierboven al staat.
De oplossing stond in een python script van de SunSpec organisatie:
https://github.com/sunspec/pysunspec/blob/master/sunspec/core/modbus/client.py
Daaruit blijkt dat je nog iets vóór die serie bytes moet plaatsen, namelijk twee keer 0 en dan de lengte van het request. Dan krijg je:
Tx: 00 00 00 00 00 06 01 03 9C 40 00 7A EB AD
N.B.: De Sunspec standaard gaat uit van words (2 bytes); daarom is de lengte 6 en niet 12.
En dan krijg je dus wel antwoord van de inverter!
Nog een heel gedoe om te decoderen, maar uiteindelijk heb ik er een service van gemaakt die elke 10 seconden de data van mijn inverters ophaalt. En dat werkt uitstekend.
Mocht je interesse hebben in mijn script, laat het even weten.
Groet van Ton
[size=xsmall]
Toevoeging op 17/09/2023 00:57:14:[/size]
Ik plak het script hier gelijk maar onder.
Noot: ik heb niet veel ervaring met php, dus het zal hier en daar makkelijker of beter kunnen. Suggesties zijn welkom!
Verder staan er specifieke onderdelen in voor mijn omgeving; daar moet je maar overheen lezen.
<?php
// SolarEdge Inverter ModBus/TCP client script / SunSpec protocol
// ing. Ton Rutgers 24-8-2023
// Purpose: Monitor SolarEdge inverters through ModBus/TCP
// Starten op XigmaNAS in een Putty venster:
// /usr/local/bin/php-cgi /mnt/Fileserver/Web/modbusTCP/SolarEdge_modbus_tcp.php
// OR
// php-cgi /mnt/Fileserver/Web/modbusTCP/SolarEdge_modbus_tcp.php
// Stoppen met CTRL-C
// Updates:
// 2023-09-16: using rrdCmd($command) now for rrdtool commands (instead of exec($command)
// rrdtool errors are now logged to rrdtool.log
// corrected socket error message texts
// removed "die()" if a socket could not be created; instead skip this interval
/* References:
https://github.com/sunspec/pysunspec/blob/master/sunspec/core/modbus/client.py -> here the mystery of the requeststring is revealed
https://www.phphulp.nl/php/forum/topic/modbus-tcp-registers-uitlezen-solaredge/103803/last/ -> this was not really helpful
*/
include '/mnt/Fileserver/Web/phpbase/dbConnect.php';
include '/mnt/Fileserver/Web/phpbase/phpFunctions.php';
$debug = 0; // will echo the output also to the terminal
$ipSE12K = "192.168.178.75"; // IP address of SE12.5K inverter
$ipSE10K = "192.168.178.76"; // IP address of SE10K inverter
$serverPort = 1502; // Modbus/TCP port of SolarEdge inverter
$TSformat = "Y-m-d H:i:s"; // timestamp format
error_reporting(E_ALL & ~E_NOTICE); // report all PHP messages, except "Notice"
// open logfile and append
$logfile = fopen("/mnt/Fileserver/Web/modbusTCP/modbusTCP.log", "a") or die("Unable to open file!");
$txt = date($TSformat) . " INFO modbusTCP client started at port $serverPort\n";
fwrite($logfile, $txt);
// get properties of inverters from database, devicetypeID = 3
$conn = OpenCon();
if ($conn->connect_error) {
$txt = date($TSformat) . " ERROR Could not connect to mySQL. Exiting \n";
fwrite($logfile, $txt);
die();
}
$sql = "select deviceID, locatie from device where devicetypeID = 3";
$result = $conn->query($sql);
if ($result->num_rows < 1) {
$txt = date($TSformat) . " ERROR No inverters in database. Exiting \n";
fwrite($logfile, $txt);
die();
}
$i = 0;
while ( $row = $result->fetch_assoc() ) {
$deviceID[$i] = $row["deviceID"];
$IPinverter[$i] = $row["locatie"];
$i++;
}
$nInverter = $i;
CloseCon($conn); // close database connection
for ($i = 0; $i < $nInverter; $i++) {
$txt = date($TSformat) . " INFO logging inverter with deviceID $deviceID[$i] ip $IPinverter[$i] \n";
fwrite($logfile, $txt);
}
// request related parameters
$TCP_HEADER = 0; // no idea what this is for, but it part of the header
$TCP_REQ_LEN = 6; // request length
$id = 1; // inverter device id
$fc = 3; // ?
$regaddr = 40000; // register base address
$length = 122-8; // length of SunSpec Common model plus three-phase inverter model. IN WORDS!
// Note: there are 17 bytes at the end who contain no info, so exclude them
$requestString = pack ("nnnCCnn", $TCP_HEADER, $TCP_HEADER, $TCP_REQ_LEN, $id, $fc, $regaddr, $length);
if ($debug) {
$lenbin = strlen($requestString);
echo "Len of binary string: $lenbin bytes \n";
// decode binary requeststring back into array for verification
$a = unpack ("n1h1/n1h2/n1len/C1id/C1fc/n1address/n1length", $requestString); // but unpack in hi-lo order, so the representaion is as expected
$a1 = implode(",", $a);
print_r ($a);
}
$startTime = round(microtime(true), -1); // seconds since 1-1-1970, rounded to multiples of 10 seconds to align the time to the interval
$interval = 10; // process every 10 seconds;
while (true) { // loop forever
usleep(500000); // go to sleep for 500ms
if (microtime(true) - $startTime >= $interval) {
$startTime += $interval; // renew $starttime
$conn = OpenCon();
if ($conn->connect_error) {
$txt = date($TSformat) . " ERROR Could not connec to mySQL. Skip this interval \n";
fwrite($logfile, $txt);
}
else {
$timestamp = date($TSformat); // use same timestamp for all inverters
for ($i = 0; $i < $nInverter; $i++) {
getLog($deviceID[$i], $IPinverter[$i], $serverPort, $requestString, $length, $conn, $logfile, $timestamp, $TSformat, $debug);
}
}
CloseCon($conn); // close database connection
}
}
fclose($logfile); // this will probably never happen, because of the endless loop above
die("Terminated");
// END OF MAIN LOOP
function getLog($deviceID, $serverIP, $serverPort, $requestString, $length, $conn, $logfile, $timestamp, $TSformat, $debug) {
$RRDNAME = "InvLogReadings$deviceID.rrd"; // include deviceID in rrd log archive
$STORAGE_PATH = "/mnt/Fileserver/Web/www/dbLogging/rrd";
$RRDFILE = "$STORAGE_PATH/archives/$RRDNAME";
if ($debug) {
$t0 = microtime(true); // for time measurement
echo "Get data from deviceID $deviceID ip $serverIP \n";
}
if (!$socket = socket_create(AF_INET, SOCK_STREAM, SOL_TCP)) {
$errorcode = socket_last_error();
$errormsg = socket_strerror($errorcode);
$txt = date($TSformat) . " ip=$serverIP socket_create errmsg: $errormsg\n";
fwrite($logfile, $txt);
return;
}
if (!socket_connect($socket, $serverIP, $serverPort)) {
$errorcode = socket_last_error();
$errormsg = socket_strerror($errorcode);
$txt = date($TSformat) . " ip=$serverIP socket_connect errmsg: $errormsg\n";
fwrite($logfile, $txt);
socket_close($socket);
return;
}
if (!socket_write($socket, $requestString)) {
$errorcode = socket_last_error();
$errormsg = socket_strerror($errorcode);
$txt = date($TSformat) . " ip=$serverIP socket_write errmsg: $errormsg\n";
fwrite($logfile, $txt);
socket_close($socket);
return;
}
$buf = "";
// we are expecting exactly $length number of words = 2 * $length bytes
if (!socket_recv($socket, $buf , 2 * $length, MSG_WAITALL )) {
$errorcode = socket_last_error();
$errormsg = socket_strerror($errorcode);
$txt = date($TSformat) . " ip=$serverIP socket_recv errmsg: $errormsg\n";
fwrite($logfile, $txt);
socket_close($socket);
return;
}
socket_close($socket);
// DECODE the buffer
// Note: the first 0 ... 8 (9 bytes) are not part of the model; what are they?
// the first 5 bytes are 0x00
// byte 6 = 247 (0xE7)
// byte 7 = 0x01 -> device ID?
// byte 8 = 0x03 -> model number?
// byte 9 = 244 (0xE4)
// Between the Common model and the inverter model there are bytes who do not belong to a model; they are skipped with a 'filler item'
// At the end of the message there are multiple irrelevant bytes; leave them undecoded
// I did not discover a crc at the end of the message, so we are not going to use the CRC16 function
$buf = substr($buf, 9); // get rid of the first 9 bytes
$data = unpack('a4C_SunSpec_ID/n1C_SunSpec_DID1/n1C_sunSpec_length1/a32C_Manufacturer/a32C_model/a16C_Version/a32C_SerialNumber/n1C_DeviceAddress/a16filler/n1C_SunSpec_DID2/n1C_sunSpec_length2/n1I_AC_Current/n1I_AC_CurrentA/n1I_AC_CurrentB/n1I_AC_CurrentC/n1I_AC_Current_SF/n1I_AC_VoltageAB/n1I_AC_VoltageBC/n1I_AC_VoltageCA/n1I_AC_VoltageAN/n1I_AC_VoltageBN/n1I_AC_VoltageCN/n1I_AC_Voltage_SF/n1I_AC_Power/n1I_AC_Power_SF/n1I_AC_Frequency/n1I_AC_Frequency_SF/n1I_AC_VA/n1I_AC_VA_SF/n1I_AC_VAR/n1I_AC_VAR_SF/n1I_AC_PF/n1I_AC_PF_SF/N1I_AC_Energy_WH/n1I_AC_Energy_WH_SF/n1I_DC_Current/n1I_DC_Current_SF/n1I_DC_Voltage/n1I_DC_Voltage_SF/n1I_DC_Power/n1I_DC_Power_SF/n1I_Temp_Cab/n1I_Temp_Sink/n1I_Temp_Transf/n1I_Temp_Other/n1I_Temp_SF/n1I_Status/n1I_Status_Vendor', $buf);
// correct int's who are unpacked as uint16, because php unpack does not provide unpack of signed short in big endian format
$data["I_AC_Current_SF"] = correctInt($data["I_AC_Current_SF"]);
$data["I_AC_Voltage_SF"] = correctInt($data["I_AC_Voltage_SF"]);
$data["I_AC_Power"] = correctInt($data["I_AC_Power"]);
$data["I_AC_Power_SF"] = correctInt($data["I_AC_Power_SF"]);
$data["I_AC_Frequency_SF"] = correctInt($data["I_AC_Frequency_SF"]);
$data["I_AC_VA"] = correctInt($data["I_AC_VA"]);
$data["I_AC_VA_SF"] = correctInt($data["I_AC_VA_SF"]);
$data["I_AC_VAR"] = correctInt($data["I_AC_VAR"]);
$data["I_AC_VAR_SF"] = correctInt($data["I_AC_VAR_SF"]);
$data["I_AC_PF"] = correctInt($data["I_AC_PF"]);
$data["I_AC_PF_SF"] = correctInt($data["I_AC_PF_SF"]);
$data["I_AC_Energy_WH_SF"] = correctInt($data["I_AC_Energy_WH_SF"]);
$data["I_DC_Current_SF"] = correctInt($data["I_DC_Current_SF"]);
$data["I_DC_Voltage_SF"] = correctInt($data["I_DC_Voltage_SF"]);
$data["I_DC_Power"] = correctInt($data["I_DC_Power"]);
$data["I_DC_Power_SF"] = correctInt($data["I_DC_Power_SF"]);
$data["I_Temp_Cab"] = correctInt($data["I_Temp_Cab"]);
$data["I_Temp_Sink"] = correctInt($data["I_Temp_Sink"]);
$data["I_Temp_Transf"] = correctInt($data["I_Temp_Transf"]);
$data["I_Temp_Other"] = correctInt($data["I_Temp_Other"]);
$data["I_Temp_SF"] = correctInt($data["I_Temp_SF"]);
// append to array $data calculated scaled float values
$data["F_AC_Current"] = scale($data["I_AC_Current"], $data["I_AC_Current_SF"]);
$data["F_AC_CurrentA"] = scale($data["I_AC_CurrentA"], $data["I_AC_Current_SF"]);
$data["F_AC_CurrentB"] = scale($data["I_AC_CurrentB"], $data["I_AC_Current_SF"]);
$data["F_AC_CurrentC"] = scale($data["I_AC_CurrentC"], $data["I_AC_Current_SF"]);
$data["F_AC_CurrentC"] = scale($data["I_AC_CurrentC"], $data["I_AC_Current_SF"]);
$data["F_AC_VoltageAB"] = scale($data["I_AC_VoltageAB"], $data["I_AC_Voltage_SF"]);
$data["F_AC_VoltageBC"] = scale($data["I_AC_VoltageBC"], $data["I_AC_Voltage_SF"]);
$data["F_AC_VoltageCA"] = scale($data["I_AC_VoltageCA"], $data["I_AC_Voltage_SF"]);
$data["F_AC_VoltageAN"] = scale($data["I_AC_VoltageAN"], $data["I_AC_Voltage_SF"]);
$data["F_AC_VoltageBN"] = scale($data["I_AC_VoltageBN"], $data["I_AC_Voltage_SF"]);
$data["F_AC_VoltageCN"] = scale($data["I_AC_VoltageCN"], $data["I_AC_Voltage_SF"]);
$data["F_AC_Power"] = scale($data["I_AC_Power"], $data["I_AC_Power_SF"]);
$data["F_AC_Frequency"] = scale($data["I_AC_Frequency"], $data["I_AC_Frequency_SF"]);
$data["F_AC_VA"] = scale($data["I_AC_VA"], $data["I_AC_VA_SF"]);
$data["F_AC_VAR"] = scale($data["I_AC_VAR"], $data["I_AC_VAR_SF"]);
$data["F_AC_PF"] = scale($data["I_AC_PF"], $data["I_AC_PF_SF"]);
$data["F_AC_Energy_WH"] = scale($data["I_AC_Energy_WH"], $data["I_AC_Energy_WH_SF"]);
$data["F_DC_Current"] = scale($data["I_DC_Current"], $data["I_DC_Current_SF"]);
$data["F_DC_Voltage"] = scale($data["I_DC_Voltage"], $data["I_DC_Voltage_SF"]);
$data["F_DC_Power"] = scale($data["I_DC_Power"], $data["I_DC_Power_SF"]);
$data["F_Temp_Sink"] = scale($data["I_Temp_Sink"], $data["I_Temp_SF"]);
// append to array calculated values for power per phase. The inverter model does not supply these values, but I want them to compare with the smart meter values.
$data["F_AC_PowerA"] = $data["F_AC_CurrentA"] * $data["F_AC_VoltageAN"] * abs($data["F_AC_PF"]/100); // power (W) for phase A, assuming the general power factor is valid for this phase
$data["F_AC_PowerB"] = $data["F_AC_CurrentB"] * $data["F_AC_VoltageBN"] * abs($data["F_AC_PF"]/100); // power (W) for phase B, assuming the general power factor is valid for this phase
$data["F_AC_PowerC"] = $data["F_AC_CurrentC"] * $data["F_AC_VoltageCN"] * abs($data["F_AC_PF"]/100); // power (W) for phase C, assuming the general power factor is valid for this phase
if ($debug) print_r($data);
// Values to be logged in the database, per device (= inverter)
// F_AC_Energy_WH this is a counter, constantly increasing
// F_AC_Power
// F_AC_VA
// F_AC_VAR
// F_AC_PF
// F_DC_Current
// F_DC_Voltage
// F_DC_Power
// F_Temp_Sink
// I_Status
// insert values into database table. Note: timestamp must be enquoted for mysql
$values = "'$timestamp'" . ',' . $deviceID . ',' . $data['F_AC_Energy_WH'] . ',' . $data['F_AC_Power'] . ',' . $data['F_AC_PowerA'] . ',' . $data['F_AC_PowerB'] . ',' . $data['F_AC_PowerC'] . ',' . $data['F_AC_VA'] . ',' . $data['F_AC_VAR'] . ',' . $data['F_AC_PF'] . ',' . $data['F_DC_Current'] . ',' . $data['F_DC_Voltage'] . ',' . $data['F_DC_Power'] . ',' . $data['F_Temp_Sink'] . ',' . $data['I_Status'];
$sql = "INSERT INTO logInverter(timestamp, deviceID, AC_Energy_WH, AC_Power, AC_PowerA, AC_PowerB, AC_PowerC, AC_VA, AC_VAR, AC_PF, DC_Current, DC_Voltage, DC_Power, Temp_Sink, I_Status) VALUES ($values)";
$result = $conn->query($sql);
if ($result != 1) {
$txt = date($TSformat) . " ERROR Insert failure for deviceID $deviceID \n";
fwrite($logfile, $txt);
}
// Store the data also in the RRD archive; create RRD-file if necessary
// Values to be logged in rrd-archive, each device in it's own rrd:
// F_AC_CurrentA
// F_AC_CurrentB
// F_AC_CurrentC
// F_AC_VoltageAN
// F_AC_VoltageBN
// F_AC_VoltageCN
// F_AC_Power
// F_AC_VA
// F_AC_VAR
// F_AC_PF
// F_DC_Current
// F_DC_Voltage
// F_DC_Power
// F_Temp_Sink
if( !file_exists($RRDFILE) ){
// create the RRD database
// RRA 1: 2 day
// RRA 2: 2 week
// RRA 3: 2 month (31 days)
// RRA 4: 2 year (366 days)
$command = "/usr/local/bin/rrdtool \
create $RRDFILE \
--step 10 \
DS:AC_CurrentA:GAUGE:20:0:U \
DS:AC_CurrentB:GAUGE:20:0:U \
DS:AC_CurrentC:GAUGE:20:0:U \
DS:AC_VoltageAN:GAUGE:20:0:U \
DS:AC_VoltageBN:GAUGE:20:0:U \
DS:AC_VoltageCN:GAUGE:20:0:U \
DS:AC_Power:GAUGE:20:U:U \
DS:AC_VA:GAUGE:20:U:U \
DS:AC_VAR:GAUGE:20:U:U \
DS:AC_PF:GAUGE:20:U:U \
DS:DC_Current:GAUGE:20:0:U \
DS:DC_Voltage:GAUGE:20:0:U \
DS:DC_Power:GAUGE:20:U:U \
DS:Temp_Sink:GAUGE:20:U:U \
RRA:AVERAGE:0.5:1:17280 \
RRA:AVERAGE:0.5:7:17280 \
RRA:AVERAGE:0.5:31:17280 \
RRA:AVERAGE:0.5:366:17280";
if (!rrdCmd($command)) {
$txt = date($TSformat) . " ERROR Could not create rrd archive for deviceID $deviceID Check rrdtool.log \n";
fwrite($logfile, $txt);
}
}
// update archive
$values = $data["F_AC_CurrentA"] . ':' . $data["F_AC_CurrentB"] . ':' . $data["F_AC_CurrentC"] . ':' . $data["F_AC_VoltageAN"] . ':' . $data["F_AC_VoltageBN"] . ':' . $data["F_AC_VoltageCN"] . ':' . $data["F_AC_Power"] . ':' . $data["F_AC_VA"] . ':' . $data["F_AC_VAR"] . ':' . $data["F_AC_PF"] . ':' . $data["F_DC_Current"] . ':' . $data["F_DC_Voltage"] . ':' . $data["F_DC_Power"] . ':' . $data["F_Temp_Sink"];
if (!rrdCmd ( "/usr/local/bin/rrdtool update $RRDFILE N:$values" )) {
$txt = date($TSformat) . " ERROR could not update rrd archive for deviceID $deviceID Check rrdtool.log \n";
fwrite($logfile, $txt);
}
if ($debug) {
$timeUsed = microtime(true) - $t0;
echo date($TSformat) . " -> done for $serverIP in $timeUsed seconds\n";
}
}
function scale($inVal, $scalef) {
// purpose: convert integer input value to float, given the scale factor
return (float)($inVal * pow(10, $scalef));
}
function correctInt($uint16) {
// unint16 format, to be converted to an int format
$int = $uint16;
if ($uint16 >= 32768) $int = $uint16 - 65536;
return $int;
}
function crc16($data) {
$crc16 = 0xFFFF;
for ($i = 0; $i < strlen($data); $i++)
{
$crc16 ^=ord($data[$i]);
for ($j = 8; $j !=0; $j--)
{
if (($crc16 & 0x0001) !=0)
{
$crc16 >>= 1;
$crc16 ^= 0xA001;
}
else
$crc16 >>= 1;
}
}
return $crc16;
}
?>
Groet van Ton