Added modbus retry (2 times max) if there is an error during decode of message (this can be rare but) Refactor the code using the modbus function Added string converted data from Average() class Commented the Max Class since it is not used in this module Bumped version
473 lines
18 KiB
Python
473 lines
18 KiB
Python
#!/usr/bin/env python
|
|
"""
|
|
Victron Energy Multiplus II plugin
|
|
Author: Xavier Beaudouin
|
|
Requirements:
|
|
1. multiplus + GX
|
|
2. pymodbus AND pymodbusTCP
|
|
"""
|
|
"""
|
|
<plugin key="VictronEnergy_MultiplusII" name="Victron Energy Multiplus II + Modbus" author="Xavier Beaudouin" version="0.0.2" externallink="https://github.com/xbeaudouin/victron-energy-domoticz/mppt">
|
|
<params>
|
|
<param field="Address" label="GX IP Address" width="150px" required="true" />
|
|
<param field="Port" label="GX Modbus Port Number" width="100px" required="true" default="502" />
|
|
<param field="Mode3" label="GX Modbus address" width="100px" required="true" default="100" />
|
|
<param field="Mode4" label="Multiplus Modbus address" width="100px" required="true" default="228" />
|
|
<param field="Mode5" label="Battery Modbus address" width="100px" required="true" default="225" />
|
|
<param field="Mode6" label="Debug" width="100px">
|
|
<options>
|
|
<option label="True" value="Debug"/>
|
|
<option label="False" value="Normal" default="true" />
|
|
</options>
|
|
</param>
|
|
</params>
|
|
</plugin>
|
|
"""
|
|
|
|
import Domoticz
|
|
import sys
|
|
|
|
sys.path.append('/usr/local/lib/python3.4/dist-packages')
|
|
sys.path.append('/usr/local/lib/python3.5/dist-packages')
|
|
sys.path.append('/usr/local/lib/python3.6/dist-packages')
|
|
sys.path.append('/usr/local/lib/python3.7/dist-packages')
|
|
sys.path.append('/usr/local/lib/python3.8/dist-packages')
|
|
sys.path.append('/usr/local/lib/python3.9/dist-packages')
|
|
sys.path.append('/usr/local/lib/python3.10/dist-packages')
|
|
|
|
import pymodbus
|
|
|
|
from pyModbusTCP.client import ModbusClient
|
|
from pymodbus.constants import Endian
|
|
from pymodbus.payload import BinaryPayloadDecoder
|
|
|
|
#
|
|
# Domoticz shows graphs with intervals of 5 minutes.
|
|
# When collecting information from the inverter more frequently than that, then it makes no sense to only show the last value.
|
|
#
|
|
# The Average class can be used to calculate the average value based on a sliding window of samples.
|
|
# The number of samples stored depends on the interval used to collect the value from the inverter itself.
|
|
#
|
|
|
|
class Average:
|
|
|
|
def __init__(self):
|
|
self.samples = []
|
|
self.max_samples = 30
|
|
|
|
def set_max_samples(self, max):
|
|
self.max_samples = max
|
|
if self.max_samples < 1:
|
|
self.max_samples = 1
|
|
|
|
def update(self, new_value, scale = 0):
|
|
self.samples.append(new_value * (10 ** scale))
|
|
while (len(self.samples) > self.max_samples):
|
|
del self.samples[0]
|
|
|
|
Domoticz.Debug("Average: {} - {} values".format(self.get(), len(self.samples)))
|
|
|
|
def get(self):
|
|
return sum(self.samples) / len(self.samples)
|
|
|
|
def strget(self):
|
|
return str(sum(self.samples) / len(self.samples))
|
|
|
|
|
|
#
|
|
# Domoticz shows graphs with intervals of 5 minutes.
|
|
# When collecting information from the inverter more frequently than that, then it makes no sense to only show the last value.
|
|
#
|
|
# The Maximum class can be used to calculate the highest value based on a sliding window of samples.
|
|
# The number of samples stored depends on the interval used to collect the value from the inverter itself.
|
|
#
|
|
|
|
#class Maximum:
|
|
#
|
|
# def __init__(self):
|
|
# self.samples = []
|
|
# self.max_samples = 30
|
|
#
|
|
# def set_max_samples(self, max):
|
|
# self.max_samples = max
|
|
# if self.max_samples < 1:
|
|
# self.max_samples = 1
|
|
#
|
|
# def update(self, new_value, scale = 0):
|
|
# self.samples.append(new_value * (10 ** scale))
|
|
# while (len(self.samples) > self.max_samples):
|
|
# del self.samples[0]
|
|
#
|
|
# Domoticz.Debug("Maximum: {} - {} values".format(self.get(), len(self.samples)))
|
|
#
|
|
# def get(self):
|
|
# return max(self.samples)
|
|
|
|
# Plugin itself
|
|
class BasePlugin:
|
|
def __init__(self):
|
|
# AC IN Voltage for last 5 minutes
|
|
self.acInVoltage=Average()
|
|
# AC IN Current for last 5 minutes
|
|
self.acInCurrent=Average()
|
|
# AC IN Power for last 5 minutes
|
|
self.acInPower=Average()
|
|
# AC IN Frequency for last 5 minutes
|
|
self.acInFrequency=Average()
|
|
# AC Out Voltage for last 5 minutes
|
|
self.acOutVoltage=Average()
|
|
# AC Out Current for last 5 minutes
|
|
self.acOutCurrent=Average()
|
|
# AC Out Power for last 5 minutes
|
|
self.acOutPower=Average()
|
|
# AC Out Frequency for last 5 minutes
|
|
self.acOutFrequency=Average()
|
|
# Battery Voltage for last 5 minutes
|
|
self.batteryVoltage=Average()
|
|
# Battery Current for last 5 minutes
|
|
self.batteryCurrent=Average()
|
|
# Battery SOC for last 5 minutes
|
|
self.batterySoc=Average()
|
|
# Battery Temperature for last 5 minutes
|
|
self.batteryTemp=Average()
|
|
# Grid Power for last 5 minutes
|
|
self.gridpower=Average()
|
|
# Consumption for last 5 minutes
|
|
self.conso=Average()
|
|
# PV on output for last 5 minutes
|
|
self.pv=Average()
|
|
# Battery power on last 5 minutes
|
|
self.batteryPower=Average()
|
|
|
|
return
|
|
|
|
def onStart(self):
|
|
try:
|
|
Domoticz.Log("Victron Energy Multiplus-II Modbus loaded!, using python v" + sys.version[:6] + " and pymodbus v" + pymodbus.__version__)
|
|
except:
|
|
Domoticz.Log("Victron Energy Multiplus-II Modbus loaded!")
|
|
|
|
# Check dependancies
|
|
try:
|
|
if (float(Parameters["DomoticzVersion"][:6]) < float("2020.2")): Domoticz.Error("WARNING: Domoticz version is outdated or not supported. Please update!")
|
|
if (float(sys.version[:1]) < 3): Domoticz.Error("WARNING: Python3 should be used !")
|
|
if (float(pymodbus.__version__[:3]) < float("2.3")): Domoticz.Error("WARNING: pymodbus version is outdated, please update!")
|
|
except:
|
|
Domoticz.Error("Warning ! Dependancies could not be checked !")
|
|
|
|
# Parse parameters
|
|
|
|
# Debug
|
|
if Parameters["Mode6"] == "Debug":
|
|
Domoticz.Debugging(1)
|
|
else:
|
|
Domoticz.Debugging(0)
|
|
|
|
self.IPAddress = Parameters["Address"]
|
|
self.IPPort = Parameters["Port"]
|
|
self.MBAddr = int(Parameters["Mode3"])
|
|
self.MultiAddr = int(Parameters["Mode4"])
|
|
self.BattAddr = int(Parameters["Mode5"])
|
|
|
|
|
|
Domoticz.Debug("Query IP " + self.IPAddress + ":" + str(self.IPPort) +" on GX device : "+str(self.MBAddr)+" Multi Device : "+str(self.MultiAddr)+" and Battery : "+str(self.BattAddr))
|
|
|
|
# Create the devices if they does not exists
|
|
# Multiplus Devices
|
|
if 1 not in Devices:
|
|
Domoticz.Device(Name="Voltage IN L1", Unit=1, TypeName="Voltage", Used=0).Create()
|
|
if 2 not in Devices:
|
|
Domoticz.Device(Name="Current IN L1", Unit=2, TypeName="Current (Single)", Used=0).Create()
|
|
if 3 not in Devices:
|
|
Options = { "Custom": "1;W" }
|
|
Domoticz.Device(Name="Power IN L1", Unit=3, TypeName="Custom", Used=0, Options=Options).Create()
|
|
if 4 not in Devices:
|
|
Options = { "Custom": "1;Hz" }
|
|
Domoticz.Device(Name="Frequency IN L1", Unit=4, TypeName="Custom", Used=0, Options=Options).Create()
|
|
if 5 not in Devices:
|
|
Domoticz.Device(Name="Voltage OUT L1", Unit=5, TypeName="Voltage", Used=0).Create()
|
|
if 6 not in Devices:
|
|
Domoticz.Device(Name="Current OUT L1", Unit=6, TypeName="Current (Single)", Used=0).Create()
|
|
if 7 not in Devices:
|
|
Options = { "Custom": "1;W" }
|
|
Domoticz.Device(Name="Power OUT L1", Unit=7, TypeName="Custom", Used=0, Options=Options).Create()
|
|
if 8 not in Devices:
|
|
Options = { "Custom": "1;Hz" }
|
|
Domoticz.Device(Name="Frequency OUT L1", Unit=8, TypeName="Custom", Used=0, Options=Options).Create()
|
|
if 9 not in Devices:
|
|
Domoticz.Device(Name="Grid Lost", Unit=9, TypeName="Alert", Used=0).Create()
|
|
if 10 not in Devices:
|
|
Domoticz.Device(Name="VE.Bus State", Unit=10, TypeName="Text", Used=0).Create()
|
|
|
|
# Battery
|
|
if 20 not in Devices:
|
|
Domoticz.Device(Name="Battery Voltage", Unit=20, TypeName="Voltage", Used=0).Create()
|
|
if 21 not in Devices:
|
|
Domoticz.Device(Name="Battery Current", Unit=21, TypeName="Current (Single)", Used=0).Create()
|
|
if 22 not in Devices:
|
|
Domoticz.Device(Name="Battery SOC", Unit=22, TypeName="Percentage", Used=0).Create()
|
|
if 23 not in Devices:
|
|
Domoticz.Device(Name="Battery Temperature", Unit=23, TypeName="Temperature", Used=0).Create()
|
|
|
|
# Victron
|
|
if 30 not in Devices:
|
|
Options = { "Custom": "1;W" }
|
|
Domoticz.Device(Name="Grid Power L1", Unit=30, TypeName="Custom", Used=0, Options=Options).Create()
|
|
if 31 not in Devices:
|
|
Options = { "Custom": "1;W" }
|
|
Domoticz.Device(Name="Consumption L1", Unit=31, TypeName="Custom", Used=0, Options=Options).Create()
|
|
if 32 not in Devices:
|
|
Options = { "Custom": "1;W" }
|
|
Domoticz.Device(Name="PV on Output", Unit=32, TypeName="Custom", Used=0, Options=Options).Create()
|
|
if 33 not in Devices:
|
|
Options = { "Custom": "1;W" }
|
|
Domoticz.Device(Name="Battery Power", Unit=33, TypeName="Custom", Used=0, Options=Options).Create()
|
|
if 34 not in Devices:
|
|
Domoticz.Device(Name="ESS Battery Life State", Unit=34, TypeName="Text", Used=0).Create()
|
|
if 35 not in Devices:
|
|
Domoticz.Device(Name="ESS Battery Life SoC Limit", Unit=35, TypeName="Percentage", Used=0).Create()
|
|
|
|
return
|
|
|
|
|
|
def onStop(self):
|
|
Domoticz.Debugging(0)
|
|
|
|
def onHeartbeat(self):
|
|
|
|
# Multiplus devices
|
|
Domoticz.Debug("Multiplus Interface : IP="+self.IPAddress +", Port="+str(self.IPPort)+" ID="+str(self.MultiAddr))
|
|
try:
|
|
client = ModbusClient(host=self.IPAddress, port=self.IPPort, unit_id=self.MultiAddr, auto_open=True, auto_close=True, timeout=2)
|
|
except:
|
|
Domoticz.Error("Error connecting to TCP/Interface on address : "+self.IPaddress+":"+str(self.IPPort))
|
|
# Set value to 0 -> Error on all devices
|
|
Devices[1].Update(1, "0")
|
|
Devices[2].Update(1, "0")
|
|
Devices[3].Update(1, "0")
|
|
Devices[4].Update(1, "0")
|
|
Devices[5].Update(1, "0")
|
|
Devices[6].Update(1, "0")
|
|
Devices[7].Update(1, "0")
|
|
Devices[8].Update(1, "0")
|
|
Devices[9].Update(1, "0")
|
|
Devices[10].Update(1, "0")
|
|
|
|
# Ac In Voltage
|
|
self.acInVoltage.update(round(getmodbus16(3, client)/10.0, 3))
|
|
Devices[1].Update(1, self.acInVoltage.strget())
|
|
|
|
# Ac In Current
|
|
self.acInCurrent.update(round(getmodbus16(6, client)/10.0, 3))
|
|
Devices[2].Update(1, self.acInCurrent.strget())
|
|
|
|
# Ac In Power
|
|
self.acInPower.update(round(getmodbus16(12, client)/0.1, 3))
|
|
Devices[3].Update(1, self.acInPower.strget())
|
|
|
|
# Ac In Frequency
|
|
self.acInFrequency.update(round(getmodbus16(9, client)/100.0, 3))
|
|
Devices[4].Update(1, self.acInFrequency.strget())
|
|
|
|
# Ac Out Voltage
|
|
self.acOutVoltage.update(round(getmodbus16(15, client)/10.0, 3))
|
|
Devices[5].Update(1, self.acOutVoltage.strget())
|
|
|
|
# Ac Out Current
|
|
self.acOutCurrent.update(round(getmodbus16(18, client)/10.0, 3))
|
|
Devices[6].Update(1, self.acOutCurrent.strget())
|
|
|
|
# Ac Out Power
|
|
self.acOutPower.update(round(getmodbus16(23, client)/0.1, 3))
|
|
Devices[7].Update(1, self.acOutPower.strget())
|
|
|
|
# Ac Out Frequency
|
|
self.acOutFrequency.update(round(getmodbus16(21, client)/100.0, 3))
|
|
Devices[8].Update(1, self.acOutFrequency.strget())
|
|
|
|
# Grid lost
|
|
value = getmodbus16(61, client)
|
|
if value == 0:
|
|
Devices[9].Update(nValue=value, sValue="Ok")
|
|
elif value == 2:
|
|
Devices[9].Update(nValue=value, sValue="Alert - Grid Lost")
|
|
else:
|
|
Devices[9].Update(nValue=3, sValue="Unknown state ?")
|
|
|
|
# VE.Bus state
|
|
value = getmodbus16(31, client)
|
|
vebus = 'Unknown?'
|
|
if value == 0:
|
|
vebus = 'Off'
|
|
elif value == 1:
|
|
vebus = 'Low Power'
|
|
elif value == 2:
|
|
vebus = 'Fault'
|
|
elif value == 3:
|
|
vebus = 'Bulk'
|
|
elif value == 4:
|
|
vebus = 'Absorption'
|
|
elif value == 5:
|
|
vebus = 'Float'
|
|
elif value == 6:
|
|
vebus = 'Storage'
|
|
elif value == 7:
|
|
vebus = 'Equalize'
|
|
elif value == 8:
|
|
vebus = 'Passthru'
|
|
elif value == 9:
|
|
vebus = 'Inverting'
|
|
elif value == 10:
|
|
vebus = 'Power assist'
|
|
elif value == 11:
|
|
vebus = 'Power supply'
|
|
Devices[10].Update(1, str(value)+": "+vebus)
|
|
|
|
|
|
# Multiplus devices
|
|
Domoticz.Debug("Multiplus Interface : IP="+self.IPAddress +", Port="+str(self.IPPort)+" ID="+str(self.BattAddr))
|
|
try:
|
|
battery = ModbusClient(host=self.IPAddress, port=self.IPPort, unit_id=self.BattAddr, auto_open=True, auto_close=True, timeout=2)
|
|
except:
|
|
Domoticz.Error("Error connecting to TCP/Interface on address : "+self.IPaddress+":"+str(self.IPPort))
|
|
# Set value to 0 -> Error on all devices
|
|
Devices[20].Update(1, "0")
|
|
Devices[21].Update(1, "0")
|
|
Devices[22].Update(1, "0")
|
|
Devices[23].Update(1, "0")
|
|
|
|
# Battery Voltage
|
|
self.batteryVoltage.update(round(getmodbus16(259, battery)/100.0, 3))
|
|
Devices[20].Update(1, self.batteryVoltage.strget())
|
|
|
|
# Battery Current
|
|
self.batteryCurrent.update(round(getmodbus16(261, battery)/10.0,3))
|
|
Devices[21].Update(1, self.batteryCurrent.strget())
|
|
|
|
# Battery SOC
|
|
self.batterySoc.update(round(getmodbus16(266, battery)/10.0,3))
|
|
Devices[22].Update(1, self.batterySoc.strget())
|
|
|
|
# Battery Temperature
|
|
self.batteryTemp.update(round(getmodbus16(262, battery)/10.0,3))
|
|
Devices[23].Update(1, self.batteryTemp.strget())
|
|
|
|
# Victron devices
|
|
Domoticz.Debug("Multiplus Interface : IP="+self.IPAddress +", Port="+str(self.IPPort)+" ID="+str(self.MBAddr))
|
|
try:
|
|
victron = ModbusClient(host=self.IPAddress, port=self.IPPort, unit_id=self.MBAddr, auto_open=True, auto_close=True, timeout=2)
|
|
except:
|
|
Domoticz.Error("Error connecting to TCP/Interface on address : "+self.IPaddress+":"+str(self.IPPort))
|
|
# Set value to 0 -> Error on all devices
|
|
Devices[30].Update(1, "0")
|
|
Devices[31].Update(1, "0")
|
|
Devices[32].Update(1, "0")
|
|
Devices[33].Update(1, "0")
|
|
Devices[34].Update(1, "0")
|
|
Devices[35].Update(1, "0")
|
|
|
|
# Grid Power L1
|
|
self.gridpower.update(getmodbus16(820, victron))
|
|
Devices[30].Update(1, self.gridpower.strget())
|
|
|
|
# Consumption L1
|
|
self.conso.update(getmodbus16(817, victron))
|
|
Devices[31].Update(1, self.conso.strget())
|
|
|
|
# PV on Output
|
|
self.pv.update(getmodbus16(808, victron))
|
|
Devices[32].Update(1, self.pv.strget())
|
|
|
|
# Battery Power
|
|
self.batteryPower.update(getmodbus16(842, victron))
|
|
Devices[33].Update(1, self.batteryPower.strget())
|
|
|
|
# ESS Battery State
|
|
value = getmodbus16(2900, victron)
|
|
batterystate = "Unknown?"
|
|
onbattery = 0
|
|
if value == 0:
|
|
batterystate = "Unused, Battery Life Disabled"
|
|
elif value == 1:
|
|
batterystate = "Restarted"
|
|
elif value == 2:
|
|
batterystate = "Self-compsumption"
|
|
onbattery = 1
|
|
elif value == 3:
|
|
batterystate = "Self-compsumption, SoC exceeds 85%"
|
|
onbattery = 1
|
|
elif value == 4:
|
|
batterystate = "Self-compsumption, SoC at 100%"
|
|
onbattery = 1
|
|
elif value == 5:
|
|
batterystate = "Discharge disabled"
|
|
elif value == 6:
|
|
batterystate = "Force Charge"
|
|
elif value == 7:
|
|
batterystate = "Sustain"
|
|
elif value == 9:
|
|
batterystate = "Keep batteries charged"
|
|
elif value == 10:
|
|
batterystate = "Battery Life disabled"
|
|
elif value == 11:
|
|
batterystate = "Battery Life disabled (low SoC)"
|
|
Devices[34].Update(1, str(value)+": "+batterystate)
|
|
# TODO: add a device to say on battery yes/no
|
|
# use the "onbattery" variable
|
|
|
|
# ESS Battery Life SoC Limit
|
|
value = (getmodbus16(2903, victron) / 10.0)
|
|
Devices[35].Update(1, str(value))
|
|
|
|
global _plugin
|
|
_plugin = BasePlugin()
|
|
|
|
def onStart():
|
|
global _plugin
|
|
_plugin.onStart()
|
|
|
|
def onStop():
|
|
global _plugin
|
|
_plugin.onStop()
|
|
|
|
def onHeartbeat():
|
|
global _plugin
|
|
_plugin.onHeartbeat()
|
|
|
|
# Generic helper functions
|
|
def DumpConfigToLog():
|
|
for x in Parameters:
|
|
if Parameters[x] != "":
|
|
Domoticz.Debug( "'" + x + "':'" + str(Parameters[x]) + "'")
|
|
Domoticz.Debug("Device count: " + str(len(Devices)))
|
|
for x in Devices:
|
|
Domoticz.Debug("Device: " + str(x) + " - " + str(Devices[x]))
|
|
Domoticz.Debug("Device ID: '" + str(Devices[x].ID) + "'")
|
|
Domoticz.Debug("Device Name: '" + Devices[x].Name + "'")
|
|
Domoticz.Debug("Device nValue: " + str(Devices[x].nValue))
|
|
Domoticz.Debug("Device sValue: '" + Devices[x].sValue + "'")
|
|
Domoticz.Debug("Device LastLevel: " + str(Devices[x].LastLevel))
|
|
return
|
|
|
|
|
|
# get Modbus 16 bits values
|
|
def getmodbus16(register, client):
|
|
value = 0
|
|
try:
|
|
data = client.read_holding_registers(register, 1)
|
|
Domoticz.Debug("Data from register "+str(register)+": "+str(data))
|
|
decoder = BinaryPayloadDecoder.fromRegisters(data, byteorder=Endian.Big, wordorder=Endian.Big)
|
|
value = decoder.decode_16bit_int()
|
|
except:
|
|
Domoticz.Error("Error getting data from "+str(register) + ", try 1")
|
|
try:
|
|
data = client.read_holding_registers(register, 1)
|
|
Domoticz.Debug("Data from register "+str(register)+": "+str(data))
|
|
decoder = BinaryPayloadDecoder.fromRegisters(data, byteorder=Endian.Big, wordorder=Endian.Big)
|
|
value = decoder.decode_16bit_int()
|
|
except:
|
|
Domoticz.Error("Error getting data from "+str(register) + ", try 2")
|
|
|
|
return value
|
|
|