Skip to content

Commit

Permalink
feat(modbus): add modbus features and example config
Browse files Browse the repository at this point in the history
  • Loading branch information
MichaelErmer committed Mar 9, 2023
1 parent 545e1f2 commit fc19080
Show file tree
Hide file tree
Showing 3 changed files with 185 additions and 30 deletions.
10 changes: 9 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,17 +70,25 @@ rm main.zip
### Change config.ini
Within the project there is a file `/data/dbus-solax-x1-pvinverter/config.ini` - just change the values

If you add MODBUS to your config the SOLAXCLOUD settings will be ignored.

To use with only one phase (X1 devices) just remove Phase2 and Phase3 from the INVERTER.PHASES section.

| Section | Config vlaue | Explanation |
| ------------- | ------------- | ------------- |
| DEFAULT | SignOfLifeLog | Time in minutes how often a status is added to the log-file `current.log` with log-level INFO |
| MODBUS | port | The port of the modbus adapter to use ie. /dev/ttyUSB0 |
| MODBUS | unit | The modbus unit id, if multiple units share the same modbus, default = 1 |
| SOLAXCLOUD | Endpoint | API endpoint - should always be the same |
| SOLAXCLOUD | TokenId | TokenId from Solax Cloud portal |
| SOLAXCLOUD | RegNo | RegNo of inverter WifiStick |
| INVERTER | Position | Refer to Victron documentation - 0=AC input 1; 1=AC output; 2=AC input 2 |
| INVERTER | MaxPower | Inverter max AC power in watts |
| INVERTER | GridVoltage | The voltage is not returned by RESTapi so we use this value to calculate the current based on power |
| INVERTER | Phase | Phase your inverter is connected to |

| INVERTER.PHASES | Phase1 | Name of Phase 1, ie. L1 |
| INVERTER.PHASES | Phase2 | Name of Phase 2, ie. L2 |
| INVERTER.PHASES | Phase3 | Name of Phase 3, ie. L3 |

## Used documentation
- https://github.com/victronenergy/venus/wiki/dbus#grid DBus paths for Victron namespace
Expand Down
12 changes: 11 additions & 1 deletion config.ini
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
[DEFAULT]
SignOfLifeLog=5

[MODBUS]
#unit=1
port=/dev/ttyUSB0

[SOLAXCLOUD]
Endpoint=https://www.eu.solaxcloud.com:9443/proxy/api/getRealtimeInfo.do
TokenId=12345678901234567890
Expand All @@ -10,4 +14,10 @@ RegNo=COOLREGNO
Position=0
MaxPower=5000
GridVoltage=230
Phase=L1
Phase=L1
#SN=INVERTERSERIAL

[INVERTER.PHASES]
Phase1=L1
Phase2=L2
Phase3=L3
193 changes: 165 additions & 28 deletions dbus-solax-x1-pvinverter.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
import sys
import os
import sys
if sys.version_info.major == 2:
if sys.version_info.major > 2:
import gobject
else:
from gi.repository import GLib as gobject
Expand All @@ -19,12 +19,20 @@
sys.path.insert(1, os.path.join(os.path.dirname(__file__), '/opt/victronenergy/dbus-systemcalc-py/ext/velib_python'))
from vedbus import VeDbusService

import solaxx3rs485

class DbusSolaxX1Service:
def __init__(self, servicename, deviceinstance, paths, productname='Solax X1', connection='192.168.2.111- 126 (sunspec)'):
# detect modus
self._source = "cloud"
if (config['MODBUS']):
self._source = "modbus"
self._modbus = solaxx3rs485.SolaxX3RS485Client(config['MODBUS']['port'])

# victron service
self._dbusservice = VeDbusService("{}.pv_{}".format(servicename, self._getSolaxInverterSerial()))
self._paths = paths

logging.debug("%s /DeviceInstance = %d" % (servicename, deviceinstance))

# Create the management objects, as specified in the ccgx dbus-api document
Expand All @@ -48,16 +56,32 @@ def __init__(self, servicename, deviceinstance, paths, productname='Solax X1', c
self._dbusservice.add_path('/UpdateIndex', 0)

# add path values to dbus
for path, settings in self._paths.items():
self._dbusservice.add_path(
self._replacePhaseVar(path), settings['initial'], gettextcallback=settings['textformat'], writeable=True, onchangecallback=self._handlechangedvalue)

if (self._source == "modbus" and config['INVERTER.PHASES']):
for key in config['INVERTER.PHASES']:
phase = config['INVERTER.PHASES'][key]
for path, settings in self._paths.items():
self._dbusservice.add_path(
self._replacePhaseVar(path, phase), settings['initial'], gettextcallback=settings['textformat'], writeable=True, onchangecallback=self._handlechangedvalue)
else:
for path, settings in self._paths.items():
self._dbusservice.add_path(
self._replacePhaseVar(path), settings['initial'], gettextcallback=settings['textformat'], writeable=True, onchangecallback=self._handlechangedvalue)

# last update
self._lastUpdate = 0
self._lastCloudUpdate = 0
self._lastCloudCheck = 0
self._lastCloudCheck = 0
self._lastCloudACPower = 0
self._lastCloudInverterStatus = 0
self._lastPhase1Power = 0
self._lastPhase2Power = 0
self._lastPhase3Power = 0
self._lastPhase1Voltage = 0
self._lastPhase2Voltage = 0
self._lastPhase3Voltage = 0
self._lastPhase1Current = 0
self._lastPhase2Current = 0
self._lastPhase3Current = 0

# add _update function 'timer'
gobject.timeout_add(500, self._update) # call update routine
Expand All @@ -83,12 +107,17 @@ def _getSignOfLifeInterval(self):


def _getSolaxInverterSerial(self):
data = self._getSolaxCloudData()
serial = "default"
config = self._getConfig()
if config['INVERTER']['SN']
serial = config['INVERTER']['SN']

if not data['result']['inverterSN']:
if (self._source == "cloud"):
data = self._getSolaxCloudData()
if not data['result']['inverterSN']:
raise ValueError("Response does not contain 'mac' attribute")

serial = data['result']['inverterSN']
serial = data['result']['inverterSN']
return serial


Expand All @@ -105,9 +134,9 @@ def _getPhaseFromConfig(self):
return result


def _replacePhaseVar(self, input):
def _replacePhaseVar(self, input, phase=self._getPhaseFromConfig()):
result = input
result = result.replace("[*Phase*]", self._getPhaseFromConfig())
result = result.replace("[*Phase*]", phase)
return result


Expand Down Expand Up @@ -151,9 +180,60 @@ def _getSolaxCloudData(self):
raise ValueError("Response (%s) is not ok - 'success'=%s 'exception'=%s" % ( URL, data['success'], data['exception']))

return data

def _getInverterStatusRunMode(self, solaxRunMode: int):
# * Status as returned by the fronius inverter
# * - 0-6: Startup
# * - 7: Running
# * - 8: Standby
# * - 9: Boot loading
# * - 10: Error
status = 10

# Run Mode Codes from solax docs
if solaxRunMode == 0:
# Waiting
status = 8
elif solaxRunMode == 1:
# Checking
status = 0
elif solaxRunMode == 2:
# Normal
status = 7
elif solaxRunMode == 3:
# Fault
status = 10
elif solaxRunMode == 4:
# Permanent Fault
status = 10
elif solaxRunMode == 5:
# Update
status = 10
elif solaxRunMode == 6:
# Off-grid waiting
status = 8
elif solaxRunMode == 7:
# Off-grid
status = 8
elif solaxRunMode == 8:
# Self Testing
status = 1
elif solaxRunMode == 9:
# Idle
status = 8
elif solaxRunMode == 10:
# Standby
status = 8

return status


def _getInverterStatus(self, solaxInverterStatusCode: int):
def _getInverterStatus(self, solaxInverterStatusCode: int):
# * Status as returned by the fronius inverter
# * - 0-6: Startup
# * - 7: Running
# * - 8: Standby
# * - 9: Boot loading
# * - 10: Error
status = 10

if solaxInverterStatusCode in (100,101) :
Expand Down Expand Up @@ -206,10 +286,10 @@ def _update(self):
logging.debug("---");

# some general data
grid_voltage = self._getGridVoltage()
grid_voltage = self._getGridVoltage()

#send data to DBus
if self._lastCloudCheck == 0 or (time.time()-self._lastCloudCheck) >= 15:
#get data from solax cloud
if self._modus == "cloud" and (self._lastCloudCheck == 0 or (time.time()-self._lastCloudCheck) >= 15):
#get data from Solax Cloud
meter_data = self._getSolaxCloudData()
self._lastCloudCheck = time.time()
Expand All @@ -225,18 +305,75 @@ def _update(self):
logging.debug("Cloud Update - Inverter status-code: %s" % (self._getInverterStatus(self._lastCloudInverterStatus)))
logging.debug("Cloud Update - AC power: %s" % (self._lastCloudACPower))
logging.debug("Cloud Update - AC energy total: %s" % (self._lastCloudACEnergyTotal))

#get data from modbus
if self._modus == "modbus":
#we can query this in every loop, not just every 5minutes :)
meter_data = self._modbus.get_data()
self._lastModbusCheck = time.time()

self._lastModbusUpdate = time.time()
# power
self.lastPhase1Power = meter_data.output_power_phase_1
self.lastPhase2Power = meter_data.output_power_phase_2
self.lastPhase3Power = meter_data.output_power_phase_3

# currents
self.lastPhase1Current = meter_data.output_current_phase_1
self.lastPhase2Current = meter_data.output_current_phase_2
self.lastPhase3Current = meter_data.output_current_phase_3

# voltage data
self.lastPhase1Voltage = meter_data.grid_voltage_phase_1
self.lastPhase2Voltage = meter_data.grid_voltage_phase_2
self.lastPhase3Voltage = meter_data.grid_voltage_phase_3

# yield data
self.lastTotalYield = meter_data.total_yield
self.lastTodayYield = meter_data.yield_today

# data of each pv string
self.lastPv1Power = meter_data.pv1_dc_power
self.lastPv1Current = meter_data.pv1_input_current
self.lastPv1Voltage = meter_data.pv1_input_voltage
self.lastPv2Power = meter_data.pv2_dc_power
self.lastPv2Current = meter_data.pv2_input_current
self.lastPv2Voltage = meter_data.pv2_input_voltage

self.lastStatus = self._getInverterStatusRunMode(meter_data.run_mode)


# set normal values
self._dbusservice['/Ac/Power'] = self._lastCloudACPower
self._dbusservice['/StatusCode'] = self._getInverterStatus(self._lastCloudInverterStatus)
self._dbusservice[self._replacePhaseVar('/Ac/[*Phase*]/Voltage')] = grid_voltage
self._dbusservice[self._replacePhaseVar('/Ac/[*Phase*]/Current')] = round(self._dbusservice['/Ac/Power'] / self._dbusservice[self._replacePhaseVar('/Ac/[*Phase*]/Voltage')] , 2)
self._dbusservice[self._replacePhaseVar('/Ac/[*Phase*]/Power')] = self._dbusservice['/Ac/Power']
self._dbusservice[self._replacePhaseVar('/Ac/[*Phase*]/Energy/Forward')] = self._lastCloudACEnergyTotal
self._dbusservice['/Ac/Energy/Forward'] = self._dbusservice[self._replacePhaseVar('/Ac/[*Phase*]/Energy/Forward')] # one phase pv-inverter # + self._dbusservice['/Ac/L2/Energy/Forward'] + self._dbusservice['/Ac/L3/Energy/Forward']
self._dbusservice['/Ac/Current'] = self._dbusservice[self._replacePhaseVar('/Ac/[*Phase*]/Current')] # just copy value - 1phase inverter
# set status
if self.lastStatus:
self._dbusservice['/StatusCode'] = self.lastStatus
else:
self._dbusservice['/StatusCode'] = self._getInverterStatus(self._lastCloudInverterStatus)

# set energy values
total_current = 0
total_energy_forward = 0
total_power = 0
if (self._source == "modbus" and config['INVERTER.PHASES']):
total_energy_forward = self._lastCloudACEnergyTotal
for key in config['INVERTER.PHASES']:
phase = config['INVERTER.PHASES'][key]
phase_power = self['_last'+key+'Power']
phase_voltage = self['_last'+key+'Voltage']
phase_current = self['_last'+key+'Current']
total_power = total_power + phase_power
total_curren = total_current + phase_current
self._dbusservice[self._replacePhaseVar('/Ac/[*Phase*]/Voltage', phase)] = phase_voltage
self._dbusservice[self._replacePhaseVar('/Ac/[*Phase*]/Current', phase)] = phase_current
self._dbusservice[self._replacePhaseVar('/Ac/[*Phase*]/Power', phase)] = phase_power
else:
total_power = self._lastCloudACPower
total_energy_forward = self._lastCloudACEnergyTotal
self._dbusservice[self._replacePhaseVar('/Ac/[*Phase*]/Voltage')] = grid_voltage
self._dbusservice[self._replacePhaseVar('/Ac/[*Phase*]/Current')] = round(self._lastCloudACPower / grid_voltage, 2)
self._dbusservice[self._replacePhaseVar('/Ac/[*Phase*]/Power')] = self._lastCloudACPower
self._dbusservice[self._replacePhaseVar('/Ac/[*Phase*]/Energy/Forward')] = self._lastCloudACEnergyTotal
total_current = self._dbusservice[self._replacePhaseVar('/Ac/[*Phase*]/Current')]
self._dbusservice['/Ac/Power'] = total_power
self._dbusservice['/Ac/Energy/Forward'] = total_energy_forward
self._dbusservice['/Ac/Current'] = total_current
self._dbusservice['/Ac/Voltage'] = grid_voltage


Expand Down

0 comments on commit fc19080

Please sign in to comment.