mirror of
https://github.com/alexhopeoconnor/SunGather.git
synced 2026-10-23 11:02:10 +10:00
Merge branch 'main' of https://github.com/bohdan-s/SunGather into bohdan-s-main
This commit is contained in:
@@ -23,6 +23,9 @@
|
|||||||
|
|
||||||
<!-- ABOUT THE PROJECT -->
|
<!-- ABOUT THE PROJECT -->
|
||||||
## About The Project
|
## About The Project
|
||||||
|
|
||||||
|
<b>Join the Discord Server to discuss, suggestions or for any help: <a href="https://discord.gg/7j2MVsT5wn">SunGather Discord</a></b>
|
||||||
|
|
||||||
Access ModBus data from almost any network connected Sungow Inverter.
|
Access ModBus data from almost any network connected Sungow Inverter.
|
||||||
|
|
||||||
On first connection the tool will query your inverter, retrieve the model and return the correct registers for your device. No more searching registers or creating model files.
|
On first connection the tool will query your inverter, retrieve the model and return the correct registers for your device. No more searching registers or creating model files.
|
||||||
@@ -49,6 +52,11 @@ I have learned a lot from the following projects, THANK YOU
|
|||||||
* Full Home Assistant integration, as HACS addon
|
* Full Home Assistant integration, as HACS addon
|
||||||
|
|
||||||
## Updates
|
## Updates
|
||||||
|
**0.3.2**
|
||||||
|
* Added logging to file
|
||||||
|
* Hopefully better connection recovery
|
||||||
|
* Bug fixes
|
||||||
|
|
||||||
**0.3.0**
|
**0.3.0**
|
||||||
**IMPORTANT: If updating from v0.1.x or v0.2.x please check config against config-example. some options for MQTT and PVOutput have changed**
|
**IMPORTANT: If updating from v0.1.x or v0.2.x please check config against config-example. some options for MQTT and PVOutput have changed**
|
||||||
* Heaps bug fixes
|
* Heaps bug fixes
|
||||||
|
|||||||
@@ -10,7 +10,8 @@ inverter:
|
|||||||
# See model list here: https://github.com/bohdan-s/SunGather#supported
|
# See model list here: https://github.com/bohdan-s/SunGather#supported
|
||||||
# smart_meter: True # [Optional] Default is False, Set to true if inverter supports reading grind / house consumption
|
# smart_meter: True # [Optional] Default is False, Set to true if inverter supports reading grind / house consumption
|
||||||
# use_local_time: False # [Optional] Default False, Uses Inventer time, if true it uses PC time when updating timestamps (e.g. PVOutput)
|
# use_local_time: False # [Optional] Default False, Uses Inventer time, if true it uses PC time when updating timestamps (e.g. PVOutput)
|
||||||
# logging: 30 # [Optional] 10 = Debug, 20 = Info, 30 = Warning (default), 40 = Error
|
# log_console: INFO # [Optional] Default is WARNING, Options: DEBUG, INFO, WARNING, ERROR
|
||||||
|
# log_file: DEBUG # [Optional] Default is OFF, Options: OFF, DEBUG, INFO, WARNING, ERROR
|
||||||
# level: 1 # [Optional] Set the amount of information to gather
|
# level: 1 # [Optional] Set the amount of information to gather
|
||||||
# 0 = Model and Solar Generation,
|
# 0 = Model and Solar Generation,
|
||||||
# 1 (default) = Useful data, all required for exports,
|
# 1 (default) = Useful data, all required for exports,
|
||||||
|
|||||||
@@ -54,25 +54,6 @@ class export_influxdb(object):
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
def publish(self, inverter):
|
def publish(self, inverter):
|
||||||
#HEAD
|
|
||||||
'''if not self._isConfigured:
|
|
||||||
# logging.info("InfluxDB: Skipped, Initial Configuration Failed")
|
|
||||||
# return False
|
|
||||||
|
|
||||||
# Setting a Standard Measurement Name and Inverter as a tag for later filtering
|
|
||||||
# could be extended by using values from config.yaml or SerialNo or ...
|
|
||||||
# Maybe also better switch to p=influxdb_client.Point(xxx).Tag().Field()
|
|
||||||
sequence= f"measure1,inverter={inverter.get('device_type_code', 'unknown').replace('.','').replace('-','')} "
|
|
||||||
|
|
||||||
for measurement in self.measurements:
|
|
||||||
sequence += f"{measurement.get('register')}={inverter.get(measurement.get('register'),0)},"
|
|
||||||
|
|
||||||
# remove last ","
|
|
||||||
sequence=sequence[:-1]
|
|
||||||
logging.debug(f'InfluxDB: Sequence; {sequence}')
|
|
||||||
'''
|
|
||||||
# ---------
|
|
||||||
|
|
||||||
sequence = []
|
sequence = []
|
||||||
|
|
||||||
for measurement in self.influxdb_measurements:
|
for measurement in self.influxdb_measurements:
|
||||||
@@ -82,7 +63,6 @@ class export_influxdb(object):
|
|||||||
sequence.append(f"{measurement['point']},inverter={inverter.getInverterModel(True)} {measurement['register']}={inverter.getRegisterValue(measurement['register'])}")
|
sequence.append(f"{measurement['point']},inverter={inverter.getInverterModel(True)} {measurement['register']}={inverter.getRegisterValue(measurement['register'])}")
|
||||||
logging.debug(f'InfluxDB: Sequence; {sequence}')
|
logging.debug(f'InfluxDB: Sequence; {sequence}')
|
||||||
|
|
||||||
# f11ee5fe75be6aa1bc8046901443122e4116b702
|
|
||||||
try:
|
try:
|
||||||
self.write_api.write(self.influxdb_config['bucket'], self.client.org, sequence)
|
self.write_api.write(self.influxdb_config['bucket'], self.client.org, sequence)
|
||||||
except Exception as err:
|
except Exception as err:
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ class export_mqtt(object):
|
|||||||
self.mqtt_queue.remove(mid)
|
self.mqtt_queue.remove(mid)
|
||||||
except Exception as err:
|
except Exception as err:
|
||||||
pass
|
pass
|
||||||
logging.info(f"MQTT: Message {mid} Published")
|
logging.debug(f"MQTT: Message {mid} Published")
|
||||||
|
|
||||||
def cleanName(self, name):
|
def cleanName(self, name):
|
||||||
return name.lower().replace(' ','_')
|
return name.lower().replace(' ','_')
|
||||||
@@ -70,11 +70,13 @@ class export_mqtt(object):
|
|||||||
def publish(self, inverter):
|
def publish(self, inverter):
|
||||||
if not self.mqtt_client.is_connected():
|
if not self.mqtt_client.is_connected():
|
||||||
logging.warning(f'MQTT: Server Disconnected; {self.mqtt_queue.__len__()} messages queued, will automatically attempt to reconnect')
|
logging.warning(f'MQTT: Server Disconnected; {self.mqtt_queue.__len__()} messages queued, will automatically attempt to reconnect')
|
||||||
elif self.mqtt_queue.__len__() > 10:
|
# qos=0 is set, so no acknowledgment is sent, rending this check useless
|
||||||
logging.warning(f'MQTT: {self.mqtt_queue.__len__()} messages queued, this may be due to a MQTT server issue')
|
#elif self.mqtt_queue.__len__() > 10:
|
||||||
|
# logging.warning(f'MQTT: {self.mqtt_queue.__len__()} messages queued, this may be due to a MQTT server issue')
|
||||||
|
|
||||||
logging.debug(f"MQTT: Publishing: {self.mqtt_config['topic']} : {json.dumps(inverter.latest_scrape)}")
|
logging.debug(f"MQTT: Publishing: {self.mqtt_config['topic']} : {json.dumps(inverter.latest_scrape)}")
|
||||||
self.mqtt_queue.append(self.mqtt_client.publish(self.mqtt_config['topic'], json.dumps(inverter.latest_scrape).replace('"', '\"'), qos=0).mid)
|
self.mqtt_queue.append(self.mqtt_client.publish(self.mqtt_config['topic'], json.dumps(inverter.latest_scrape).replace('"', '\"'), qos=0).mid)
|
||||||
|
logging.info(f"MQTT: Published")
|
||||||
|
|
||||||
if self.mqtt_config['homeassistant'] and not self.ha_discovery_published:
|
if self.mqtt_config['homeassistant'] and not self.ha_discovery_published:
|
||||||
for ha_sensor in self.ha_sensors:
|
for ha_sensor in self.ha_sensors:
|
||||||
|
|||||||
+68
-29
@@ -14,11 +14,6 @@ import yaml
|
|||||||
import time
|
import time
|
||||||
import os
|
import os
|
||||||
|
|
||||||
logging.basicConfig(
|
|
||||||
format='%(asctime)s %(levelname)-8s %(message)s',
|
|
||||||
level=20,
|
|
||||||
datefmt='%Y-%m-%d %H:%M:%S')
|
|
||||||
|
|
||||||
class SungrowInverter():
|
class SungrowInverter():
|
||||||
def __init__(self, config_inverter):
|
def __init__(self, config_inverter):
|
||||||
self.client_config = {
|
self.client_config = {
|
||||||
@@ -48,10 +43,9 @@ class SungrowInverter():
|
|||||||
|
|
||||||
def connect(self):
|
def connect(self):
|
||||||
if self.client:
|
if self.client:
|
||||||
if(self.client.connect()):
|
try: self.client.connect()
|
||||||
|
except: return False
|
||||||
return True
|
return True
|
||||||
else:
|
|
||||||
self.disconnect()
|
|
||||||
|
|
||||||
if self.inverter_config['connection'] == "http":
|
if self.inverter_config['connection'] == "http":
|
||||||
self.client_config['port'] = '8082'
|
self.client_config['port'] = '8082'
|
||||||
@@ -65,20 +59,34 @@ class SungrowInverter():
|
|||||||
return False
|
return False
|
||||||
logging.info("Connection: " + str(self.client))
|
logging.info("Connection: " + str(self.client))
|
||||||
|
|
||||||
retval = self.client.connect()
|
try: self.client.connect()
|
||||||
# Wait 3 seconds, fixes timing issues
|
except: return False
|
||||||
time.sleep(3)
|
|
||||||
if not self.inverter_config['connection'] == "http":
|
time.sleep(3) # Wait 3 seconds, fixes timing issues
|
||||||
self.client.close()
|
return True
|
||||||
return retval
|
|
||||||
|
def checkConnection(self):
|
||||||
|
logging.debug("Checking Modbus Connection")
|
||||||
|
if self.client:
|
||||||
|
if self.client.is_socket_open():
|
||||||
|
logging.debug("Modbus, Session is still connected")
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
logging.info(f'Modbus, Connecting new session')
|
||||||
|
return self.connect()
|
||||||
|
else:
|
||||||
|
logging.info(f'Modbus client is not connected, attempting to reconnect')
|
||||||
|
return self.connect()
|
||||||
|
|
||||||
def close(self):
|
def close(self):
|
||||||
logging.debug("Closed session: " + str(self.client))
|
logging.info("Closing Session: " + str(self.client))
|
||||||
self.client.close()
|
try: self.client.close()
|
||||||
|
except: pass
|
||||||
|
|
||||||
def disconnect(self):
|
def disconnect(self):
|
||||||
logging.info("Disconnected: " + str(self.client))
|
logging.info("Disconnecting: " + str(self.client))
|
||||||
self.client.close()
|
try: self.client.close()
|
||||||
|
except: pass
|
||||||
self.client = None
|
self.client = None
|
||||||
|
|
||||||
def configure_registers(self,registersfile):
|
def configure_registers(self,registersfile):
|
||||||
@@ -156,6 +164,7 @@ class SungrowInverter():
|
|||||||
continue
|
continue
|
||||||
if register_range_used:
|
if register_range_used:
|
||||||
self.register_ranges.append(register_range)
|
self.register_ranges.append(register_range)
|
||||||
|
return True
|
||||||
|
|
||||||
def load_registers(self, register_type, start, count=100):
|
def load_registers(self, register_type, start, count=100):
|
||||||
try:
|
try:
|
||||||
@@ -167,11 +176,13 @@ class SungrowInverter():
|
|||||||
else:
|
else:
|
||||||
raise RuntimeError(f"Unsupported register type: {type}")
|
raise RuntimeError(f"Unsupported register type: {type}")
|
||||||
except Exception as err:
|
except Exception as err:
|
||||||
logging.warning(f'No data returned for {register_type}, {start}:{count}\n\t\t\t\t{str(err)}')
|
logging.warning(f"No data returned for {register_type}, {start}:{count}")
|
||||||
|
logging.debug(f"{str(err)}')")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
if rr.isError():
|
if rr.isError():
|
||||||
logging.warning(f"Modbus connection failed: {rr}")
|
logging.warning(f"Modbus connection failed")
|
||||||
|
logging.debug(f"{rr}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
if not hasattr(rr, 'registers'):
|
if not hasattr(rr, 'registers'):
|
||||||
@@ -271,7 +282,6 @@ class SungrowInverter():
|
|||||||
|
|
||||||
def scrape(self):
|
def scrape(self):
|
||||||
scrape_start = datetime.now()
|
scrape_start = datetime.now()
|
||||||
self.connect()
|
|
||||||
|
|
||||||
# Clear previous inverter values, keep the model and run state
|
# Clear previous inverter values, keep the model and run state
|
||||||
if self.latest_scrape.get("run_state"): run_state = self.latest_scrape.get("run_state")
|
if self.latest_scrape.get("run_state"): run_state = self.latest_scrape.get("run_state")
|
||||||
@@ -290,12 +300,14 @@ class SungrowInverter():
|
|||||||
load_registers_failed +=1
|
load_registers_failed +=1
|
||||||
if load_registers_failed == load_registers_count:
|
if load_registers_failed == load_registers_count:
|
||||||
# If every scrape fails, disconnect the client
|
# If every scrape fails, disconnect the client
|
||||||
|
logging.warning
|
||||||
self.disconnect()
|
self.disconnect()
|
||||||
return False
|
return False
|
||||||
if load_registers_failed > 0:
|
if load_registers_failed > 0:
|
||||||
logging.info(f'Scraping: {load_registers_failed}/{load_registers_count} registers failed to scrape')
|
logging.info(f'Scraping: {load_registers_failed}/{load_registers_count} registers failed to scrape')
|
||||||
|
|
||||||
self.close()
|
# Leave connection open, see if helps resolve the connection issues
|
||||||
|
#self.close()
|
||||||
|
|
||||||
# Create a registers for Power imported and exported to/from Grid
|
# Create a registers for Power imported and exported to/from Grid
|
||||||
if self.inverter_config['level'] >= 1:
|
if self.inverter_config['level'] >= 1:
|
||||||
@@ -445,20 +457,35 @@ def main():
|
|||||||
"timeout": configfile['inverter'].get('timeout',10),
|
"timeout": configfile['inverter'].get('timeout',10),
|
||||||
"retries": configfile['inverter'].get('retries',3),
|
"retries": configfile['inverter'].get('retries',3),
|
||||||
"slave": configfile['inverter'].get('slave',0x01),
|
"slave": configfile['inverter'].get('slave',0x01),
|
||||||
"scan_interval": configfile['inverter'].get('scan_interval',15),
|
"scan_interval": configfile['inverter'].get('scan_interval',30),
|
||||||
"connection": configfile['inverter'].get('connection',"modbus"),
|
"connection": configfile['inverter'].get('connection',"modbus"),
|
||||||
"model": configfile['inverter'].get('model',None),
|
"model": configfile['inverter'].get('model',None),
|
||||||
"smart_meter": configfile['inverter'].get('smart_meter',False),
|
"smart_meter": configfile['inverter'].get('smart_meter',False),
|
||||||
"use_local_time": configfile['inverter'].get('use_local_time',False),
|
"use_local_time": configfile['inverter'].get('use_local_time',False),
|
||||||
"manual_load": configfile['inverter'].get('manual_load',False),
|
"log_console": configfile['inverter'].get('log_console','WARNING'),
|
||||||
"logging": configfile['inverter'].get('logging',30),
|
"log_file": configfile['inverter'].get('log_file','OFF'),
|
||||||
"level": configfile['inverter'].get('level',1)
|
"level": configfile['inverter'].get('level',1)
|
||||||
}
|
}
|
||||||
|
|
||||||
if 'loglevel' in locals():
|
if 'loglevel' in locals():
|
||||||
logging.getLogger().setLevel(loglevel)
|
logger.handlers[0].setLevel(loglevel)
|
||||||
else:
|
else:
|
||||||
logging.getLogger().setLevel(config_inverter['logging'])
|
logger.handlers[0].setLevel(config_inverter['log_console'])
|
||||||
|
|
||||||
|
if not config_inverter['log_file'] == "OFF":
|
||||||
|
if config_inverter['log_file'] == "DEBUG" or config_inverter['log_file'] == "INFO" or config_inverter['log_file'] == "WARNING" or config_inverter['log_file'] == "ERROR":
|
||||||
|
logfile = datetime.now().strftime("%Y%m%d_%H%M%S_SunGather.log")
|
||||||
|
fh = logging.FileHandler(logfile, mode='w', encoding='utf-8')
|
||||||
|
fh.formatter = logger.handlers[0].formatter
|
||||||
|
fh.setLevel(config_inverter['log_file'])
|
||||||
|
logger.addHandler(fh)
|
||||||
|
else:
|
||||||
|
logging.warning(f"log_file: Valid options are: DEBUG, INFO, WARNING, ERROR and OFF")
|
||||||
|
|
||||||
|
logging.info(f"Logging to console set to: {logging.getLevelName(logger.handlers[0].level)}")
|
||||||
|
if logger.handlers.__len__() == 3:
|
||||||
|
logging.info(f"Logging to file set to: {logging.getLevelName(logger.handlers[2].level)}")
|
||||||
|
|
||||||
logging.debug(f'Inverter Config Loaded: {config_inverter}')
|
logging.debug(f'Inverter Config Loaded: {config_inverter}')
|
||||||
|
|
||||||
if config_inverter.get('host'):
|
if config_inverter.get('host'):
|
||||||
@@ -467,11 +494,12 @@ def main():
|
|||||||
logging.error(f"Error: host option in config is required")
|
logging.error(f"Error: host option in config is required")
|
||||||
sys.exit("Error: host option in config is required")
|
sys.exit("Error: host option in config is required")
|
||||||
|
|
||||||
if not inverter.connect():
|
if not inverter.checkConnection():
|
||||||
logging.error(f"Error: Connection to inverter failed: {config_inverter.get('host')}:{config_inverter.get('port')}")
|
logging.error(f"Error: Connection to inverter failed: {config_inverter.get('host')}:{config_inverter.get('port')}")
|
||||||
sys.exit(f"Error: Connection to inverter failed: {config_inverter.get('host')}:{config_inverter.get('port')}")
|
sys.exit(f"Error: Connection to inverter failed: {config_inverter.get('host')}:{config_inverter.get('port')}")
|
||||||
|
|
||||||
inverter.configure_registers(registersfile)
|
inverter.configure_registers(registersfile)
|
||||||
|
if not inverter.inverter_config['connection'] == "http": inverter.close()
|
||||||
|
|
||||||
# Now we know the inverter is working, lets load the exports
|
# Now we know the inverter is working, lets load the exports
|
||||||
exports = []
|
exports = []
|
||||||
@@ -493,7 +521,7 @@ def main():
|
|||||||
while True:
|
while True:
|
||||||
loop_start = datetime.now()
|
loop_start = datetime.now()
|
||||||
|
|
||||||
inverter.connect()
|
inverter.checkConnection()
|
||||||
|
|
||||||
# Scrape the inverter
|
# Scrape the inverter
|
||||||
success = inverter.scrape()
|
success = inverter.scrape()
|
||||||
@@ -501,6 +529,7 @@ def main():
|
|||||||
if(success):
|
if(success):
|
||||||
for export in exports:
|
for export in exports:
|
||||||
export.publish(inverter)
|
export.publish(inverter)
|
||||||
|
if not inverter.inverter_config['connection'] == "http": inverter.close()
|
||||||
else:
|
else:
|
||||||
inverter.disconnect()
|
inverter.disconnect()
|
||||||
logging.warning(f"Data collection failed, skipped exporting data. Retying in {scan_interval} secs")
|
logging.warning(f"Data collection failed, skipped exporting data. Retying in {scan_interval} secs")
|
||||||
@@ -520,6 +549,16 @@ def main():
|
|||||||
logging.info(f'Next scrape in {int(scan_interval - process_time)} secs')
|
logging.info(f'Next scrape in {int(scan_interval - process_time)} secs')
|
||||||
time.sleep(scan_interval - process_time)
|
time.sleep(scan_interval - process_time)
|
||||||
|
|
||||||
|
logging.basicConfig(
|
||||||
|
format='%(asctime)s %(levelname)-8s %(message)s',
|
||||||
|
level=logging.DEBUG,
|
||||||
|
datefmt='%Y-%m-%d %H:%M:%S')
|
||||||
|
|
||||||
|
logger = logging.getLogger('')
|
||||||
|
ch = logging.StreamHandler()
|
||||||
|
ch.setLevel(logging.WARNING)
|
||||||
|
logger.addHandler(ch)
|
||||||
|
|
||||||
if __name__== "__main__":
|
if __name__== "__main__":
|
||||||
main()
|
main()
|
||||||
|
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
__version__ = '0.3.1'
|
__version__ = '0.3.2'
|
||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
PyYAML>=6.0
|
PyYAML>=6.0
|
||||||
requests>=2.26.0
|
requests>=2.26.0
|
||||||
paho-mqtt>=1.5.1
|
paho-mqtt>=1.5.1
|
||||||
pymodbus>=2.4.0
|
pymodbus>=2.5.3
|
||||||
SungrowModbusTcpClient>=0.1.5
|
SungrowModbusTcpClient>=0.1.5
|
||||||
SungrowModbusWebClient>=0.2.7
|
SungrowModbusWebClient>=0.2.7
|
||||||
influxdb-client>=1.24.0
|
influxdb-client>=1.24.0
|
||||||
|
|||||||
Reference in New Issue
Block a user