mirror of
https://github.com/alexhopeoconnor/SunGather.git
synced 2026-10-04 02:48:12 +10:00
Merge branch 'bohdan-s:main' into bohdan-s-main
This commit is contained in:
+118
@@ -0,0 +1,118 @@
|
||||
config.yaml
|
||||
*.log.*
|
||||
docs/
|
||||
|
||||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
|
||||
# C extensions
|
||||
*.so
|
||||
|
||||
# Distribution / packaging
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
MANIFEST
|
||||
|
||||
# PyInstaller
|
||||
# Usually these files are written by a python script from a template
|
||||
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
||||
*.manifest
|
||||
*.spec
|
||||
|
||||
# Installer logs
|
||||
pip-log.txt
|
||||
pip-delete-this-directory.txt
|
||||
|
||||
# Unit test / coverage reports
|
||||
htmlcov/
|
||||
.tox/
|
||||
.nox/
|
||||
.coverage
|
||||
.coverage.*
|
||||
.cache
|
||||
nosetests.xml
|
||||
coverage.xml
|
||||
*.cover
|
||||
.hypothesis/
|
||||
.pytest_cache/
|
||||
|
||||
# Translations
|
||||
*.mo
|
||||
*.pot
|
||||
|
||||
# Django stuff:
|
||||
*.log
|
||||
local_settings.py
|
||||
db.sqlite3
|
||||
|
||||
# Flask stuff:
|
||||
instance/
|
||||
.webassets-cache
|
||||
|
||||
# Scrapy stuff:
|
||||
.scrapy
|
||||
|
||||
# Sphinx documentation
|
||||
docs/_build/
|
||||
|
||||
# PyBuilder
|
||||
target/
|
||||
|
||||
# Jupyter Notebook
|
||||
.ipynb_checkpoints
|
||||
|
||||
# IPython
|
||||
profile_default/
|
||||
ipython_config.py
|
||||
|
||||
# pyenv
|
||||
.python-version
|
||||
|
||||
# celery beat schedule file
|
||||
celerybeat-schedule
|
||||
|
||||
# SageMath parsed files
|
||||
*.sage.py
|
||||
|
||||
# Environments
|
||||
.env
|
||||
.venv
|
||||
env/
|
||||
venv/
|
||||
ENV/
|
||||
env.bak/
|
||||
venv.bak/
|
||||
|
||||
# Spyder project settings
|
||||
.spyderproject
|
||||
.spyproject
|
||||
|
||||
# Rope project settings
|
||||
.ropeproject
|
||||
|
||||
# mkdocs documentation
|
||||
/site
|
||||
|
||||
# mypy
|
||||
.mypy_cache/
|
||||
.dmypy.json
|
||||
dmypy.json
|
||||
|
||||
# Pyre type checker
|
||||
.pyre/
|
||||
@@ -1,4 +1,5 @@
|
||||
config.yaml
|
||||
*.log.*
|
||||
|
||||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
|
||||
+5
-2
@@ -5,13 +5,16 @@ RUN useradd sungather
|
||||
WORKDIR /usr/src/sungather
|
||||
|
||||
COPY requirements.txt ./
|
||||
RUN pip install --no-cache-dir --upgrade -r requirements.txt
|
||||
# pycryptodomex 3.14 currently fails to compile for arm64
|
||||
#RUN pip install --no-cache-dir --upgrade -r requirements.txt
|
||||
RUN pip install --no-cache-dir --upgrade pycryptodomex==3.11.0 -r requirements.txt
|
||||
|
||||
COPY SunGather/ .
|
||||
|
||||
VOLUME /logs
|
||||
VOLUME /config
|
||||
COPY SunGather/config-example.yaml /config/config.yaml
|
||||
|
||||
USER sungather
|
||||
|
||||
CMD [ "python", "sungather.py", "-c", "/config/config.yaml" ]
|
||||
CMD [ "python", "sungather.py", "-c", "/config/config.yaml", "-l", "/logs/" ]
|
||||
@@ -9,12 +9,14 @@
|
||||
<br />
|
||||
<div align="center">
|
||||
|
||||
<h2 align="center">SunGather</h3>
|
||||
<h2 align="center">SunGather</h2>
|
||||
|
||||
<p align="center">
|
||||
Collect data from Sungrow Inverters using ModbusTcpClient, <a href="https://github.com/rpvelloso/Sungrow-Modbus">SungrowModbusTcpClient</a> or <a href="https://github.com/bohdan-s/SungrowModbusWebClient">SungrowModbusWebClient</a> and export to various locations.
|
||||
<br />
|
||||
<br />
|
||||
<a href="https://sungather.app">Website Sungather.app</a>
|
||||
·
|
||||
<a href="https://github.com/bohdan-s/SunGather/issues">Report Bug</a>
|
||||
·
|
||||
<a href="https://github.com/bohdan-s/SunGather/issues">Request Feature</a>
|
||||
@@ -30,7 +32,7 @@ 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.
|
||||
|
||||
Register information based on official documentation: <a href="https://github.com/bohdan-s/Sungrow-Inverter/blob/main/Modbus%20Information/Communication%20Protocol%20of%20PV%20Grid-Connected%20String%20Inverters_V1.1.37_EN.pdf">Communication Protocol of PV Grid-Connected String Inverters</a> and <a hreh="https://github.com/bohdan-s/Sungrow-Inverter/blob/main/Modbus%20Information/communication-protocol-of-residential-hybrid-inverterv1.0.20-1.pdf">Communication Protocol of Residential Hybrid Inverters</a>
|
||||
Register information based on official documentation: <a href="https://github.com/bohdan-s/Sungrow-Inverter/blob/main/Modbus%20Information/Communication%20Protocol%20of%20PV%20Grid-Connected%20String%20Inverters_V1.1.37_EN.pdf">Communication Protocol of PV Grid-Connected String Inverters</a> and <a href="https://github.com/bohdan-s/Sungrow-Inverter/blob/main/Modbus%20Information/communication-protocol-of-residential-hybrid-inverterv1.0.20-1.pdf">Communication Protocol of Residential Hybrid Inverters</a>
|
||||
|
||||
Has multiple export locations out of the box:
|
||||
* Console - Log directly to screen
|
||||
@@ -51,64 +53,19 @@ I have learned a lot from the following projects, THANK YOU
|
||||
## Raodmap / TO DO
|
||||
* Full Home Assistant integration, as HACS addon
|
||||
|
||||
## Updates
|
||||
**0.3.2**
|
||||
* Added logging to file
|
||||
* Hopefully better connection recovery
|
||||
* Bug fixes
|
||||
|
||||
**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**
|
||||
* Heaps bug fixes
|
||||
* Fixed PVOutput not working after midnight
|
||||
* MQTT now auto-reconnects
|
||||
* MQTT/PVOutput now verify values exist before trying to publish
|
||||
* Rasberry Pi Docker support
|
||||
* lots more...
|
||||
|
||||
|
||||
**0.2.2**
|
||||
* Minor bug fixes
|
||||
* Add support for some inverter scrapes to fail
|
||||
* Add support for zero_on_standby, this will 0 registers like temp that 'stick' to the last value when the inverter goes into standy. Handy if you want to graph 0 instead of the last read overnight
|
||||
|
||||
**0.2.1**
|
||||
* Minor bug fixes
|
||||
* Updated config-example.yaml to clearly explain each option and if it is required or optional
|
||||
* Replace is_running with run_state, was using bool which was causing errors in MQTT, run_state uses "ON" and "OFF"
|
||||
|
||||
**0.2.0**
|
||||
**IMPORTANT: If updating from v0.1.x please check config against config-example. some options for MQTT and PVOutput have changed**
|
||||
* Re-write to Inverter scanning code, improved performance and more resilient to failures
|
||||
* Re-write to PVOutput code, now uploads every 5 minutes as per API documentation, averages all data points over the 5 min window to reduce random high/low values
|
||||
* Re-Write to MQTT code, now supports multiple sensor types
|
||||
|
||||
**0.1.3**
|
||||
* Improved error recovery, e.g. Inverter powers down overnight
|
||||
|
||||
**0.1.2**
|
||||
* Added InfluxDB export
|
||||
|
||||
**0.1.1**
|
||||
* Added docker image
|
||||
* Added simple http web server
|
||||
* Improved recovery when errors (See: SungrowModbusWebClient 0.2.6)
|
||||
|
||||
**0.1.0**
|
||||
Initial build
|
||||
|
||||
### Built With
|
||||
|
||||
* [Python3](https://www.python.org/)
|
||||
|
||||
### Requires
|
||||
* [paho-mqtt>=1.5.1](https://pypi.org/project/paho-mqtt/)
|
||||
* [pymodbus>=2.4.0](https://pypi.org/project/pymodbus/)
|
||||
* [SungrowModbusTcpClient>=0.1.6](https://pypi.org/project/SungrowModbusTcpClient/)
|
||||
* [SungrowModbusWebClient>=0.2.6](https://pypi.org/project/SungrowModbusWebClient/)
|
||||
* [PyYAML>=6.0] (https://pypi.org/project/PyYAML/)
|
||||
* [requests>=2.26.0](https://pypi.org/project/requests/)
|
||||
* [influxdb-client>=1.24.0](https://pypi.org/project/influxdb-client/)
|
||||
* [paho-mqtt](https://pypi.org/project/paho-mqtt/)
|
||||
* [pymodbus](https://pypi.org/project/pymodbus/)
|
||||
* [SungrowModbusTcpClient](https://pypi.org/project/SungrowModbusTcpClient/)
|
||||
* [SungrowModbusWebClient](https://pypi.org/project/SungrowModbusWebClient/)
|
||||
* [PyYAML](https://pypi.org/project/PyYAML/)
|
||||
* [requests](https://pypi.org/project/requests/)
|
||||
* [influxdb-client](https://pypi.org/project/influxdb-client/)
|
||||
|
||||
<p align="right">(<a href="#top">back to top</a>)</p>
|
||||
|
||||
@@ -130,13 +87,21 @@ python3 sungather.py
|
||||
```
|
||||
|
||||
# Docker
|
||||
docker run options:
|
||||
-v {path to}/config.yaml:/config/config.yaml <- Set Config File Location
|
||||
-v {logpath}:/logs <- Set Log folder Location if using log to file (also set log_file in config.yaml)
|
||||
-e TZ=Australia/Sydney <- Set Timezone
|
||||
-p 8080:8080 <- Set Web server port (only if using the webserver export)
|
||||
|
||||
```sh
|
||||
docker pull bohdans/sungather
|
||||
docker run -v {path to}/config.yaml:/config/config.yaml -e TZ=Australia/Sydney --name sungather bohdans/sungather
|
||||
```
|
||||
or if using the webserver export
|
||||
```sh
|
||||
docker run -v {path to}/config.yaml:/config/config.yaml -e TZ=Australia/Sydney -p 8080:8080 --name sungather bohdans/sungather
|
||||
docker run -d --name sungather \
|
||||
--restart always \
|
||||
-v {path to}/config.yaml:/config/config.yaml \
|
||||
-v {logpath}:/logs \
|
||||
-e TZ=Australia/Sydney \
|
||||
-p 8080:8080 \
|
||||
bohdans/sungather
|
||||
```
|
||||
Note: replace Australia/Sydney with relevant timezone
|
||||
<p align="right">(<a href="#top">back to top</a>)</p>
|
||||
@@ -219,6 +184,8 @@ sensor.inverter_meter_power
|
||||
|
||||
```
|
||||
|
||||
The Inverter reports Power (W), but HA needs Energy (Wh).
|
||||
You can use a "Riemann sum integral" (called integration) to convert it.
|
||||
Put the following into your sensors.yaml
|
||||
```
|
||||
sensor:
|
||||
@@ -283,6 +250,7 @@ docker push bohdans/sungather -a
|
||||
|
||||
|
||||
<p align="right">(<a href="#top">back to top</a>)</p>
|
||||
|
||||
<!-- LICENSE -->
|
||||
## License
|
||||
|
||||
@@ -290,7 +258,6 @@ Distributed under the GPL3 License. See `LICENSE.txt` for more information.
|
||||
|
||||
<p align="right">(<a href="#top">back to top</a>)</p>
|
||||
|
||||
|
||||
<!-- CONTACT -->
|
||||
## Contact
|
||||
|
||||
@@ -298,7 +265,6 @@ Project Link: [https://github.com/bohdan-s/SungrowModbusWebClient](https://githu
|
||||
|
||||
<p align="right">(<a href="#top">back to top</a>)</p>
|
||||
|
||||
|
||||
<!-- ACKNOWLEDGMENTS -->
|
||||
## Acknowledgments
|
||||
|
||||
|
||||
@@ -68,8 +68,12 @@ class export_mqtt(object):
|
||||
return name.lower().replace(' ','_')
|
||||
|
||||
def publish(self, inverter):
|
||||
if not self.mqtt_client.is_connected():
|
||||
logging.warning(f'MQTT: Server Disconnected; {self.mqtt_queue.__len__()} messages queued, will automatically attempt to reconnect')
|
||||
try:
|
||||
if not self.mqtt_client.is_connected():
|
||||
logging.warning(f'MQTT: Server Disconnected; {self.mqtt_queue.__len__()} messages queued, will automatically attempt to reconnect')
|
||||
except Exception as err:
|
||||
logging.warning(f'MQTT: Server Error; Server not configured')
|
||||
return False
|
||||
# qos=0 is set, so no acknowledgment is sent, rending this check useless
|
||||
#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')
|
||||
|
||||
@@ -125,10 +125,17 @@ class export_pvoutput(object):
|
||||
# Add new data to old data and increase count of data points
|
||||
for parameter in self.pvoutput_parameters:
|
||||
value = inverter.getRegisterValue(parameter.get('register'))
|
||||
|
||||
if parameter.get('multiple'):
|
||||
value = value * parameter.get('multiple')
|
||||
|
||||
if self.collected_data.get(parameter.get('name'),False):
|
||||
# If using Cumulative Energy we just need the last data point, not the average
|
||||
if parameter.get('name') == 'v1' and (self.pvoutput_config['cumulative_flag'] == 1 or self.pvoutput_config['cumulative_flag'] == 2):
|
||||
self.collected_data[parameter.get('name')] = value
|
||||
elif parameter.get('name') == 'v3' and (self.pvoutput_config['cumulative_flag'] == 1 or self.pvoutput_config['cumulative_flag'] == 3):
|
||||
self.collected_data[parameter.get('name')] = value
|
||||
# Add the last data point to the previous data point if exists, otherwise set as the last data point
|
||||
elif self.collected_data.get(parameter.get('name'),False):
|
||||
self.collected_data[parameter.get('name')] = round(self.collected_data[parameter.get('name')] + value,3)
|
||||
else:
|
||||
self.collected_data[parameter.get('name')] = value
|
||||
@@ -143,73 +150,77 @@ class export_pvoutput(object):
|
||||
return True
|
||||
|
||||
def publish(self, inverter):
|
||||
self.collect_data(inverter)
|
||||
|
||||
# Process data points every status_interval
|
||||
if((time.time() - self.last_publish) >= (self.status_interval * 60)):
|
||||
any_data = False
|
||||
if inverter.validateLatestScrape('timestamp'):
|
||||
now = datetime.datetime.strptime(inverter.getRegisterValue('timestamp'), "%Y-%m-%d %H:%M:%S")
|
||||
data_point = str(now.strftime("%Y%m%d")) + "," + str(now.strftime("%H:%M"))
|
||||
for x in range(1, 13):
|
||||
field = 'v' + str(x)
|
||||
if self.collected_data.get(field):
|
||||
if x == 6 or x == 7: # Round to 1 decimal place
|
||||
value = round(self.collected_data[field] / self.collected_data['count'], 1)
|
||||
else: # Getting errors when uploading decimals for power/energy so return INT
|
||||
value = int((self.collected_data[field] / self.collected_data['count']))
|
||||
data_point = data_point + "," + str(value)
|
||||
any_data = True
|
||||
else:
|
||||
data_point = data_point + ","
|
||||
self.collected_data = {}
|
||||
|
||||
if any_data:
|
||||
self.batch_data.append(data_point)
|
||||
else:
|
||||
logging.warning(f"PVOutput: No data collected in last {(self.status_interval * 60)} minutes")
|
||||
|
||||
# Max upload is 30, if over 30 then remove the oldest one
|
||||
if self.batch_data.__len__() > 30:
|
||||
logging.warning(f"PVOutput: Over 30 data points scheduled to upload. max is 30 so removing oldest data point")
|
||||
self.batch_data.pop(0)
|
||||
|
||||
self.batch_count +=1
|
||||
if self.batch_count >= self.pvoutput_config['batch_points']:
|
||||
if not self.batch_data.__len__() > 0:
|
||||
logging.warning(f"PVOutput: No data collected in last {((self.status_interval * 60) * self.batch_count)} minutes, Skipping upload")
|
||||
return False
|
||||
elif self.batch_data.__len__() >= 1:
|
||||
payload_data = None
|
||||
for data in self.batch_data:
|
||||
if payload_data:
|
||||
payload_data = payload_data + ";" + data
|
||||
if self.collect_data(inverter):
|
||||
# Process data points every status_interval
|
||||
if((time.time() - self.last_publish) >= (self.status_interval * 60)):
|
||||
any_data = False
|
||||
if inverter.validateLatestScrape('timestamp'):
|
||||
now = datetime.datetime.strptime(inverter.getRegisterValue('timestamp'), "%Y-%m-%d %H:%M:%S")
|
||||
data_point = str(now.strftime("%Y%m%d")) + "," + str(now.strftime("%H:%M"))
|
||||
for x in range(1, 13):
|
||||
field = 'v' + str(x)
|
||||
if self.collected_data.get(field):
|
||||
if x == 1 and (self.pvoutput_config['cumulative_flag'] == 1 or self.pvoutput_config['cumulative_flag'] == 2):
|
||||
value = int(self.collected_data[field])
|
||||
elif x == 3 and (self.pvoutput_config['cumulative_flag'] == 1 or self.pvoutput_config['cumulative_flag'] == 3):
|
||||
value = int(self.collected_data[field])
|
||||
elif x == 6 or x == 7: # Round to 1 decimal place
|
||||
value = round(self.collected_data[field] / self.collected_data['count'], 1)
|
||||
else: # Getting errors when uploading decimals for power/energy so return INT
|
||||
value = int((self.collected_data[field] / self.collected_data['count']))
|
||||
data_point = data_point + "," + str(value)
|
||||
any_data = True
|
||||
else:
|
||||
payload_data = data
|
||||
data_point = data_point + ","
|
||||
self.collected_data = {}
|
||||
|
||||
payload = {}
|
||||
payload['data'] = payload_data
|
||||
if any_data:
|
||||
self.batch_data.append(data_point)
|
||||
else:
|
||||
logging.warning(f"PVOutput: No data collected in last {(self.status_interval * 60)} minutes")
|
||||
|
||||
if self.pvoutput_config['cumulative_flag'] > 0:
|
||||
payload['c1'] = self.pvoutput_config['cumulative_flag']
|
||||
# Max upload is 30, if over 30 then remove the oldest one
|
||||
if self.batch_data.__len__() > 30:
|
||||
logging.warning(f"PVOutput: Over 30 data points scheduled to upload. max is 30 so removing oldest data point")
|
||||
self.batch_data.pop(0)
|
||||
|
||||
try:
|
||||
logging.debug("PVOutput: Request; " + self.url_addbatchstatus + ", " + str(self.headers) + " : " + str(payload))
|
||||
response = requests.post(url=self.url_addbatchstatus, headers=self.headers, params=payload, timeout=3)
|
||||
self.batch_count = 0
|
||||
self.batch_count +=1
|
||||
if self.batch_count >= self.pvoutput_config['batch_points']:
|
||||
if not self.batch_data.__len__() > 0:
|
||||
logging.warning(f"PVOutput: No data collected in last {((self.status_interval * 60) * self.batch_count)} minutes, Skipping upload")
|
||||
return False
|
||||
elif self.batch_data.__len__() >= 1:
|
||||
payload_data = None
|
||||
for data in self.batch_data:
|
||||
if payload_data:
|
||||
payload_data = payload_data + ";" + data
|
||||
else:
|
||||
payload_data = data
|
||||
|
||||
if response.status_code != requests.codes.ok:
|
||||
logging.error(f"PVOutput: Upload Failed; {str(response.status_code)} Message; {str(response.text)}")
|
||||
else:
|
||||
self.batch_data = []
|
||||
self.last_publish = time.time()
|
||||
logging.info("PVOutput: Data uploaded")
|
||||
except Exception as err:
|
||||
logging.error(f"PVOutput: Failed to Upload")
|
||||
logging.debug(f"{err}")
|
||||
payload = {}
|
||||
payload['data'] = payload_data
|
||||
|
||||
if self.pvoutput_config['cumulative_flag'] > 0:
|
||||
payload['c1'] = self.pvoutput_config['cumulative_flag']
|
||||
|
||||
try:
|
||||
logging.debug("PVOutput: Request; " + self.url_addbatchstatus + ", " + str(self.headers) + " : " + str(payload))
|
||||
response = requests.post(url=self.url_addbatchstatus, headers=self.headers, params=payload, timeout=3)
|
||||
self.batch_count = 0
|
||||
|
||||
if response.status_code != requests.codes.ok:
|
||||
logging.error(f"PVOutput: Upload Failed; {str(response.status_code)} Message; {str(response.text)}")
|
||||
logging.error("PVOutput: Request; " + self.url_addbatchstatus + ", " + str(self.headers) + " : " + str(payload))
|
||||
else:
|
||||
self.batch_data = []
|
||||
self.last_publish = time.time()
|
||||
logging.info("PVOutput: Data uploaded")
|
||||
except Exception as err:
|
||||
logging.error(f"PVOutput: Failed to Upload")
|
||||
logging.debug(f"{err}")
|
||||
else:
|
||||
logging.info("PVOutput: Data added to next batch upload")
|
||||
else:
|
||||
logging.info("PVOutput: Data added to next batch upload")
|
||||
else:
|
||||
logging.info(f"PVOutput: Data logged, next upload in {int(((self.status_interval) * 60) - (time.time() - self.last_publish))} secs")
|
||||
logging.info(f"PVOutput: Data logged, next upload in {int(((self.status_interval) * 60) - (time.time() - self.last_publish))} secs")
|
||||
|
||||
self.last_run = time.time()
|
||||
self.last_run = time.time()
|
||||
@@ -43,7 +43,7 @@ class MyServer(BaseHTTPRequestHandler):
|
||||
self.send_response(200)
|
||||
self.send_header("Content-type", "text/html")
|
||||
self.end_headers()
|
||||
self.wfile.write(bytes("<html><head><title>SunGather</title><meta http-equiv='refresh' content='15'></head>", "utf-8"))
|
||||
self.wfile.write(bytes("<html><head><title>SunGather</title><meta charset='UTF-8'><meta http-equiv='refresh' content='15'></head>", "utf-8"))
|
||||
self.wfile.write(bytes("<body>", "utf-8"))
|
||||
self.wfile.write(bytes(export_webserver.html_body, "utf-8"))
|
||||
self.wfile.write(bytes("</table>", "utf-8"))
|
||||
|
||||
+556
-110
File diff suppressed because it is too large
Load Diff
+25
-22
@@ -8,6 +8,7 @@ from datetime import datetime
|
||||
|
||||
import importlib
|
||||
import logging
|
||||
import logging.handlers
|
||||
import sys
|
||||
import getopt
|
||||
import yaml
|
||||
@@ -35,7 +36,7 @@ class SungrowInverter():
|
||||
|
||||
self.registers = [[]]
|
||||
self.registers.pop() # Remove null value from list
|
||||
self.registers_custom = [{'name': 'export_to_grid', 'unit': 'w'}, {'name': 'import_from_grid', 'unit': 'w'}, {'name': 'run_state'}]
|
||||
self.registers_custom = [{'name': 'export_to_grid', 'unit': 'W'}, {'name': 'import_from_grid', 'unit': 'W'}, {'name': 'run_state'}]
|
||||
self.register_ranges = [[]]
|
||||
self.register_ranges.pop() # Remove null value from list
|
||||
|
||||
@@ -200,12 +201,16 @@ class SungrowInverter():
|
||||
if register_type == register['type'] and register['address'] == run:
|
||||
register_name = register['name']
|
||||
|
||||
register_value = None
|
||||
# Return the 32bit value if needed
|
||||
if register.get('datatype') == "U32" or register.get('datatype') == "S32":
|
||||
register_value = rr.registers[num]
|
||||
|
||||
# Convert unsigned to signed
|
||||
if register.get('datatype') == "S32":
|
||||
u32_value = rr.registers[num+1]
|
||||
if u32_value and register_value:
|
||||
register_value = (u32_value * 65535) + register_value
|
||||
if u32_value == 65535: # Flag to say value is negative for 32bit
|
||||
register_value = (register_value - 65535)
|
||||
elif register.get('datatype') == "S16":
|
||||
if register_value >= 32767: # Anything greater than 32767 is a negative for 16bit
|
||||
register_value = (register_value - 65535)
|
||||
|
||||
# We convert a system response to a human value
|
||||
if register.get('datarange'):
|
||||
@@ -215,16 +220,8 @@ class SungrowInverter():
|
||||
if not register_value:
|
||||
register_value = rr.registers[num]
|
||||
|
||||
# Adjust the value if needed
|
||||
if register.get('indicator'):
|
||||
indicator_value = rr.registers[num+1]
|
||||
if indicator_value == 65535:
|
||||
register_value = -1 * (65535 - register_value)
|
||||
|
||||
# If xFF (U 65535 / S 32767) then change to 0, looks better when logging / graphing
|
||||
if register.get('datatype') == 'S16' and (register_value == 32767 or register_value == 65535):
|
||||
register_value = 0
|
||||
elif (register.get('datatype') == 'U16' or register.get('datatype') == 'S32' or register.get('datatype') == 'U32') and register_value == 65535:
|
||||
# If xFF / xFFFF then change to 0, looks better when logging / graphing
|
||||
if register_value == 65535:
|
||||
register_value = 0
|
||||
|
||||
if register.get('accuracy'):
|
||||
@@ -333,7 +330,7 @@ class SungrowInverter():
|
||||
# to help with graphing.
|
||||
try:
|
||||
if self.latest_scrape.get('start_stop'):
|
||||
if self.latest_scrape.get('start_stop', False) == 'Start' and self.latest_scrape.get('work_state_1', False) == 'Run':
|
||||
if self.latest_scrape.get('start_stop', False) == 'Start' and self.latest_scrape.get('work_state_1', False).contains('Run'):
|
||||
self.latest_scrape["run_state"] = "ON"
|
||||
else:
|
||||
self.latest_scrape["run_state"] = "OFF"
|
||||
@@ -398,19 +395,22 @@ class SungrowInverter():
|
||||
|
||||
def main():
|
||||
configfilename = 'config.yaml'
|
||||
logfolder = ''
|
||||
|
||||
try:
|
||||
opts, args = getopt.getopt(sys.argv[1:],"hc:v:", "runonce")
|
||||
opts, args = getopt.getopt(sys.argv[1:],"hc:l:v:", "runonce")
|
||||
except getopt.GetoptError:
|
||||
logging.debug(f'No options passed via command line')
|
||||
|
||||
for opt, arg in opts:
|
||||
if opt == '-h':
|
||||
print(f'\nSunGather {__version__}')
|
||||
print(f'\nhttps://sungather.app')
|
||||
print(f'usage: python3 sungather.py [options]')
|
||||
print(f'\nCommandling arguments override any config file settings')
|
||||
print(f'Options and arguments:')
|
||||
print(f'-c config.yaml : Specify config file.')
|
||||
print(f'-l /logs/ : Specify folder to store logs.')
|
||||
print(f'-v 30 : Logging Level, 10 = Debug, 20 = Info, 30 = Warning (default), 40 = Error')
|
||||
print(f'--runonce : Run once then exit')
|
||||
print(f'-h : print this help message and exit (also --help)')
|
||||
@@ -418,7 +418,9 @@ def main():
|
||||
print(f'python3 sungather.py -c /full/path/config.yaml\n')
|
||||
sys.exit()
|
||||
elif opt == '-c':
|
||||
configfilename = arg
|
||||
configfilename = arg
|
||||
elif opt == '-l':
|
||||
logfolder = arg
|
||||
elif opt == '-v':
|
||||
if arg.isnumeric():
|
||||
if int(arg) >= 0 and int(arg) <= 50:
|
||||
@@ -430,7 +432,7 @@ def main():
|
||||
logging.error(f"Valid verbose options: 10 = Debug, 20 = Info, 30 = Warning (default), 40 = Error")
|
||||
sys.exit(2)
|
||||
elif opt == '--runonce':
|
||||
runonce = True
|
||||
runonce = True
|
||||
|
||||
logging.info(f'Starting SunGather {__version__}')
|
||||
|
||||
@@ -447,6 +449,7 @@ def main():
|
||||
try:
|
||||
registersfile = yaml.safe_load(open('registers.yaml'))
|
||||
logging.info(f"Loaded registers: {os.getcwd()}/registers.yaml")
|
||||
logging.info(f"Registers file version: {registersfile.get('version','UNKNOWN')}")
|
||||
except Exception as err:
|
||||
logging.error(f"Failed: Loading registers: {os.getcwd()}/registers.yaml {err}")
|
||||
sys.exit(f"Failed: Loading registers: {os.getcwd()}/registers.yaml {err}")
|
||||
@@ -474,8 +477,8 @@ def main():
|
||||
|
||||
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')
|
||||
logfile = logfolder + "SunGather.log"
|
||||
fh = logging.handlers.RotatingFileHandler(logfile, mode='w', encoding='utf-8', maxBytes=10485760, backupCount=10) # Log 10mb files, 10 x files = 100mb
|
||||
fh.formatter = logger.handlers[0].formatter
|
||||
fh.setLevel(config_inverter['log_file'])
|
||||
logger.addHandler(fh)
|
||||
|
||||
@@ -1 +1 @@
|
||||
__version__ = '0.3.2'
|
||||
__version__ = '0.3.7'
|
||||
@@ -0,0 +1 @@
|
||||
sungather.app
|
||||
@@ -0,0 +1,6 @@
|
||||
theme: jekyll-theme-cayman
|
||||
plugins:
|
||||
- jekyll-relative-links
|
||||
relative_links:
|
||||
enabled: true
|
||||
collections: true
|
||||
+287
@@ -0,0 +1,287 @@
|
||||
<div id="top"></div>
|
||||
|
||||
[![Contributors][contributors-shield]][contributors-url]
|
||||
[![Forks][forks-shield]][forks-url]
|
||||
[![Stargazers][stars-shield]][stars-url]
|
||||
[![Issues][issues-shield]][issues-url]
|
||||
[![GPL3 License][license-shield]][license-url]
|
||||
|
||||
<br />
|
||||
<div align="center">
|
||||
|
||||
<h2 align="center">SunGather</h2>
|
||||
|
||||
<p align="center">
|
||||
Collect data from Sungrow Inverters using ModbusTcpClient, <a href="https://github.com/rpvelloso/Sungrow-Modbus">SungrowModbusTcpClient</a> or <a href="https://github.com/bohdan-s/SungrowModbusWebClient">SungrowModbusWebClient</a> and export to various locations.
|
||||
<br />
|
||||
<br />
|
||||
<a href="https://github.com/bohdan-s/SunGather/issues">Report Bug</a>
|
||||
·
|
||||
<a href="https://github.com/bohdan-s/SunGather/issues">Request Feature</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- 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.
|
||||
|
||||
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.
|
||||
|
||||
Register information based on official documentation: <a href="https://github.com/bohdan-s/Sungrow-Inverter/blob/main/Modbus%20Information/Communication%20Protocol%20of%20PV%20Grid-Connected%20String%20Inverters_V1.1.37_EN.pdf">Communication Protocol of PV Grid-Connected String Inverters</a> and <a href="https://github.com/bohdan-s/Sungrow-Inverter/blob/main/Modbus%20Information/communication-protocol-of-residential-hybrid-inverterv1.0.20-1.pdf">Communication Protocol of Residential Hybrid Inverters</a>
|
||||
|
||||
Has multiple export locations out of the box:
|
||||
* Console - Log directly to screen
|
||||
* MQTT - Load into MQTT, and optionally Home Assistance including Discovery
|
||||
* PVOutput - Load into PVOutput.org
|
||||
* InfluxDB - Load data directly into InfluxDB
|
||||
* Simple webserver showing collected data
|
||||
* Rasberry Pi Docker support
|
||||
* and more coming....
|
||||
|
||||
I have learned a lot from the following projects, THANK YOU
|
||||
* [solariot](https://github.com/meltaxa/solariot)
|
||||
* [modbus4mqtt](https://github.com/tjhowse/modbus4mqtt)
|
||||
* [ModbusTCP2MQTT](https://github.com/TenySmart/ModbusTCP2MQTT)
|
||||
|
||||
<p align="right">(<a href="#top">back to top</a>)</p>
|
||||
|
||||
## Raodmap / TO DO
|
||||
* Full Home Assistant integration, as HACS addon
|
||||
|
||||
### Built With
|
||||
|
||||
* [Python3](https://www.python.org/)
|
||||
|
||||
### Requires
|
||||
* [paho-mqtt>=1.5.1](https://pypi.org/project/paho-mqtt/)
|
||||
* [pymodbus>=2.4.0](https://pypi.org/project/pymodbus/)
|
||||
* [SungrowModbusTcpClient>=0.1.6](https://pypi.org/project/SungrowModbusTcpClient/)
|
||||
* [SungrowModbusWebClient>=0.2.6](https://pypi.org/project/SungrowModbusWebClient/)
|
||||
* [PyYAML>=6.0](https://pypi.org/project/PyYAML/)
|
||||
* [requests>=2.26.0](https://pypi.org/project/requests/)
|
||||
* [influxdb-client>=1.24.0](https://pypi.org/project/influxdb-client/)
|
||||
|
||||
<p align="right">(<a href="#top">back to top</a>)</p>
|
||||
|
||||
<!-- GETTING STARTED -->
|
||||
## Getting Started
|
||||
# Local
|
||||
```sh
|
||||
git clone https://github.com/bohdan-s/SunGather.git
|
||||
cd SunGather
|
||||
pip3 install --upgrade -r requirements.txt
|
||||
```
|
||||
Copy config-example.py to config.py, change values as required (see comments in file)
|
||||
```sh
|
||||
copy config-example.py config.py
|
||||
```
|
||||
Run SunGather:
|
||||
```sh
|
||||
python3 sungather.py
|
||||
```
|
||||
|
||||
# Docker
|
||||
docker run options:
|
||||
-v {path to}/config.yaml:/config/config.yaml <- Set Config File Location
|
||||
-v {logpath}:/logs <- Set Log folder Location if using log to file (also set log_file in config.yaml)
|
||||
-e TZ=Australia/Sydney <- Set Timezone
|
||||
-p 8080:8080 <- Set Web server port (only if using the webserver export)
|
||||
|
||||
```sh
|
||||
docker pull bohdans/sungather
|
||||
docker run -d --name sungather \
|
||||
--restart always \
|
||||
-v {path to}/config.yaml:/config/config.yaml \
|
||||
-v {logpath}:/logs \
|
||||
-e TZ=Australia/Sydney \
|
||||
-p 8080:8080 \
|
||||
bohdans/sungather
|
||||
```
|
||||
Note: replace Australia/Sydney with relevant timezone
|
||||
<p align="right">(<a href="#top">back to top</a>)</p>
|
||||
|
||||
|
||||
<!-- USAGE EXAMPLES -->
|
||||
## Usage
|
||||
|
||||
See config-example.py it contains default options and comments.
|
||||
|
||||
If you want to use the new Energy section in Home Assistant, follow the Home Assistant setup below.
|
||||
<p align="right">(<a href="#top">back to top</a>)</p>
|
||||
|
||||
### Commandline Arguments
|
||||
usage: python3 sungather.py [options]
|
||||
|
||||
Commandline arguments override any config file settings
|
||||
**-c config.yaml** - Specify config file.
|
||||
**-v 30** - Logging Level, 10 = Debug, 20 = Info, 30 = Warning (default), 40 = Error
|
||||
**--runonce** - Run once then exit
|
||||
**-h** - print this help message and exit (also --help)
|
||||
|
||||
Example:
|
||||
```sh
|
||||
python3 sungather.py -c /full/path/config.yaml
|
||||
```
|
||||
|
||||
## Exports
|
||||
|
||||
A collection of exports are available:
|
||||
|
||||
* console: Output information to console, useful for troubleshooting
|
||||
* webserver: Output to a simple website, default http://localhost:8080 or http://\<serverip\>:8080
|
||||
* mqtt: Output to a pre-existing MQTT server, needed for Home Assistant integration
|
||||
* pvoutput: Output to PVOutput.org, requires account and solar is set up on website first.
|
||||
* InfluxDB: Output directly to InfluxDB, can then be used by Grafana, etc..
|
||||
|
||||
## Registers
|
||||
|
||||
This tool should be able to access most registers exposed.
|
||||
Set the following in config.yaml under inverters section.
|
||||
* level: 1 - This is the most useful data for day to day
|
||||
* Level: 2 - This should be everything your inverter supports
|
||||
* Level: 3 - This will try every register, you will get lots of 0/65535 responses for registers not supported.
|
||||
|
||||
* smart_meter: True - Set to true if you have a smart reader installed, this will return power usage at the meter box, without it you can not calculate house power usage.
|
||||
|
||||
### Useful Registers:
|
||||
This is just a brief list of registers I have found useful
|
||||
|
||||
**daily_power_yields** - Total Power in kWh generated today
|
||||
**total_power_yields** - Total Power in kWh generated since inverter install
|
||||
**total_running_time** - Total Hours inverter has been powered on since install
|
||||
**internal_temperature** - Internal temperature of the Inverter
|
||||
**total_active_power** - Current power being generated by the inverter in Watts
|
||||
**meter_power** - Power usage at the meter box, needs a smart meter installed. +ve means consuming from the grid, -ve means exporting to the grid
|
||||
**load_power** - Power being consumed in total
|
||||
**export_to_grid** - How much being currently exported to the grid. This is calculated from meter_power if -ve value, returned as a positive value
|
||||
**import_from_grid** - How much being currently imported from grid. This is calculated from meter_power if +ve value
|
||||
**timestamp** - Last time data was collected, based on Inverters clock by default
|
||||
|
||||
<p align="right">(<a href="#top">back to top</a>)</p>
|
||||
|
||||
## Home Assistant setup
|
||||
|
||||
In the SunGather config.yaml you need to set the smart_meter if you have one;
|
||||
|
||||
```
|
||||
smart_meter: True
|
||||
```
|
||||
|
||||
HA sensors created by the MQTT auto-discovery;
|
||||
```
|
||||
sensor.inverter_active_power
|
||||
sensor.inverter_daily_generation
|
||||
sensor.inverter_export_to_grid
|
||||
sensor.inverter_import_from_grid
|
||||
sensor.inverter_load_power
|
||||
sensor.inverter_meter_power
|
||||
|
||||
```
|
||||
|
||||
The Inverter reports Power (W), but HA needs Energy (Wh).
|
||||
You can use a "Riemann sum integral" (called integration) to convert it.
|
||||
Put the following into your sensors.yaml
|
||||
```
|
||||
sensor:
|
||||
- platform: integration
|
||||
source: sensor.inverter_active_power
|
||||
name: Solar Production (Sungather)
|
||||
- platform: integration
|
||||
source: sensor.inverter_export_to_grid
|
||||
name: Return to Grid (Sungather)
|
||||
- platform: integration
|
||||
source: sensor.inverter_import_from_grid
|
||||
name: Grid Consumption (Sungather)
|
||||
```
|
||||
|
||||
The additional sensors then setup in HA are the following with Wh unit of measurement for the Energy platform.
|
||||
|
||||
Solar Production (Sungather)
|
||||
Return to Grid (Sungather)
|
||||
Grid Consumption (Sungather)
|
||||
|
||||
Restart Home Assistant for the sensors.yaml to be loaded.
|
||||
Make sure SunGather is running, wait 5 minutes for initial data to populate.
|
||||
Navigate to the Energy platform (Configuration > Energy). Add these sensors to the following areas:
|
||||
|
||||
Electricity Grid > Grid Consumption -> Grid Consumption (Sungather)
|
||||
Electricity Grid > Return to Grid -> Return to Grid (Sungather)
|
||||
Solar Panels > Solar Production -> Solar Production (Sungather)
|
||||
|
||||
<p align="right">(<a href="#top">back to top</a>)</p>
|
||||
|
||||
## Tested
|
||||
* SG7.0RT with WiNet-S Dongle
|
||||
* SG10RT with WiNet-S Dongle and Ethernet (Credit: @rark-ha)
|
||||
|
||||
<p align="right">(<a href="#top">back to top</a>)</p>
|
||||
|
||||
## Supported
|
||||
### PV Grid-Connected String Inverters
|
||||
SG30KTL, SG10KTL, SG12KTL, SG15KTL, SG20KTL, SG30KU, SG36KTL, SG36KU, SG40KTL, SG40KTL-M, SG50KTL-M, SG60KTL-M, SG60KU, SG30KTL-M, SG30KTL-M-V31, SG33KTL-M, SG36KTL-M, SG33K3J, SG49K5J, SG34KJ, LP_P34KSG, SG50KTL-M-20, SG60KTL, SG80KTL, SG80KTL-20, SG60KU-M, SG5KTL-MT, SG6KTL-MT, SG8KTL-M, SG10KTL-M, SG10KTL-MT, SG12KTL-M, SG15KTL-M, SG17KTL-M, SG20KTL-M, SG80KTL-M, SG111HV, SG125HV, SG125HV-20, SG30CX, SG33CX, SG36CX-US, SG40CX, SG50CX, SG60CX-US, SG110CX, SG250HX, SG250HX-US, SG100CX, SG100CX-JP, SG250HX-IN, SG25CX-SA, SG75CX, SG3.0RT, SG4.0RT, SG5.0RT, SG6.0RT, SG7.0RT, SG8.0RT, SG10RT, SG12RT, SG15RT, SG17RT, SG20RT
|
||||
|
||||
### PV Grid-Connected String Inverters Gen 2
|
||||
SG5K-D, SG8K-D
|
||||
|
||||
### Residential Hybrid Inverters
|
||||
SH5K-20, SH3K6, SH4K6, SH5K-V13, SH5K-30, SH3K6-30, SH4K6-30, SH5.0RS, SH3.6RS, SH4.6RS, SH6.0RS, SH10RT, SH8.0RT, SH6.0RT, SH5.0RT
|
||||
|
||||
<p align="right">(<a href="#top">back to top</a>)</p>
|
||||
|
||||
|
||||
## Building
|
||||
Multi Arch (BuildX) Including Raspberry Pi
|
||||
```sh
|
||||
docker run --privileged --rm tonistiigi/binfmt --install all
|
||||
docker buildx create --platform linux/amd64,linux/arm64/v8 --use
|
||||
docker buildx build --push --platform linux/amd64,linux/arm64/v8 -t bohdans/sungather:latest -t bohdans/sungather:v<version> .
|
||||
```
|
||||
Current Arch (docker build)
|
||||
```sh
|
||||
docker build --no-cache --rm -t bohdans/sungather:latest -t bohdans/sungather:v<version> .
|
||||
docker push bohdans/sungather -a
|
||||
```
|
||||
|
||||
|
||||
<p align="right">(<a href="#top">back to top</a>)</p>
|
||||
|
||||
<!-- LICENSE -->
|
||||
## License
|
||||
|
||||
Distributed under the GPL3 License. See `LICENSE.txt` for more information.
|
||||
|
||||
<p align="right">(<a href="#top">back to top</a>)</p>
|
||||
|
||||
<!-- CONTACT -->
|
||||
## Contact
|
||||
|
||||
Project Link: [https://github.com/bohdan-s/SungrowModbusWebClient](https://github.com/bohdan-s/SungrowModbusWebClient)
|
||||
|
||||
<p align="right">(<a href="#top">back to top</a>)</p>
|
||||
|
||||
<!-- ACKNOWLEDGMENTS -->
|
||||
## Acknowledgments
|
||||
|
||||
* [solariot](https://github.com/meltaxa/solariot)
|
||||
* [modbus4mqtt](https://github.com/tjhowse/modbus4mqtt)
|
||||
* [ModbusTCP2MQTT](https://github.com/TenySmart/ModbusTCP2MQTT)
|
||||
|
||||
<p align="right">(<a href="#top">back to top</a>)</p>
|
||||
|
||||
|
||||
|
||||
<!-- MARKDOWN LINKS & IMAGES -->
|
||||
<!-- https://www.markdownguide.org/basic-syntax/#reference-style-links -->
|
||||
[contributors-shield]: https://img.shields.io/github/contributors/bohdan-s/SunGather.svg?style=for-the-badge
|
||||
[contributors-url]: https://github.com/bohdan-s/SunGather/graphs/contributors
|
||||
[forks-shield]: https://img.shields.io/github/forks/bohdan-s/SunGather.svg?style=for-the-badge
|
||||
[forks-url]: https://github.com/bohdan-s/SunGather/network/members
|
||||
[stars-shield]: https://img.shields.io/github/stars/bohdan-s/SunGather.svg?style=for-the-badge
|
||||
[stars-url]: https://github.com/bohdan-s/SunGather/stargazers
|
||||
[issues-shield]: https://img.shields.io/github/issues/bohdan-s/SunGather.svg?style=for-the-badge
|
||||
[issues-url]: https://github.com/bohdan-s/SunGather/issues
|
||||
[license-shield]: https://img.shields.io/github/license/bohdan-s/SunGather.svg?style=for-the-badge
|
||||
[license-url]: https://github.com/bohdan-s/SunGather/blob/main/LICENSE.txt
|
||||
+1
-1
@@ -3,5 +3,5 @@ requests>=2.26.0
|
||||
paho-mqtt>=1.5.1
|
||||
pymodbus>=2.5.3
|
||||
SungrowModbusTcpClient>=0.1.5
|
||||
SungrowModbusWebClient>=0.2.7
|
||||
SungrowModbusWebClient>=0.3.2
|
||||
influxdb-client>=1.24.0
|
||||
|
||||
Reference in New Issue
Block a user