mirror of
https://github.com/alexhopeoconnor/SunGather.git
synced 2026-10-03 18:42:01 +10:00
Initial Commit
This commit is contained in:
@@ -1,3 +1,5 @@
|
||||
config.yaml
|
||||
|
||||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
<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</h3>
|
||||
|
||||
<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
|
||||
Access ModBus data from almost any network connected Sungow Inverter.
|
||||
|
||||
On first connection the tool will query your inverter, retreive 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>
|
||||
|
||||
Has muliple export locations out of the box:
|
||||
* Console - Log directly to screen
|
||||
* MQQT - Load into MQTT, and optionally Home Assistance Discovery
|
||||
* PVOutput - Load into PVOutput.org
|
||||
* and more coming....
|
||||
|
||||
I have borrowed HEAVILY 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>
|
||||
|
||||
### 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.4](https://pypi.org/project/SungrowModbusWebClient/)
|
||||
|
||||
<p align="right">(<a href="#top">back to top</a>)</p>
|
||||
|
||||
<!-- GETTING STARTED -->
|
||||
## Getting Started
|
||||
```sh
|
||||
git clone https://github.com/bohdan-s/SunGather.git
|
||||
```
|
||||
Copy config-example.py to config.py, change values as required (see comments in file)
|
||||
```sh
|
||||
python3 sungather.py
|
||||
```
|
||||
|
||||
<p align="right">(<a href="#top">back to top</a>)</p>
|
||||
|
||||
|
||||
|
||||
<!-- USAGE EXAMPLES -->
|
||||
## Usage
|
||||
|
||||
See config-exmaple.py it contains default options and comments.
|
||||
|
||||
|
||||
<p align="right">(<a href="#top">back to top</a>)</p>
|
||||
|
||||
|
||||
## Tested
|
||||
* SG7.0RT with WiNet-S Dongle
|
||||
|
||||
<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
|
||||
@@ -0,0 +1 @@
|
||||
from .version import __version__
|
||||
@@ -0,0 +1,102 @@
|
||||
inverter:
|
||||
host: 192.168.1.100
|
||||
# port: 502 # Default for modbus is 502, for http is 8082
|
||||
# slave: 0x01 # Default is 0x01
|
||||
# timeout: 10 # Default is 10
|
||||
# scan_interval: 30 # Default is 30
|
||||
connection: http # options: modbus, sungrow, http
|
||||
# model: "SG7.0RT" # This is autodected on startup, only needed if detection issues or for testing
|
||||
smart_meter: True # Default is False, Set to true if inverter supports reading grind / house consumption
|
||||
# logging: 30 # 10 = Debug, 20 = Info, 30 = Warnning (default), 40 = Error
|
||||
# level: 1 # 0 = Model and Solar Generation,
|
||||
# 1 (default) = Useful data, all required for exports,
|
||||
# 2 everything your Inverter supports,
|
||||
# 3 Everything from every register
|
||||
|
||||
exports:
|
||||
- name: console # Print Registers to console, good for debugging / troubleshooting
|
||||
enabled: False
|
||||
|
||||
- name: mqtt # Publish Registers to MQTT / Home Assistant
|
||||
host: 192.168.1.200
|
||||
# port: 1883
|
||||
topic: "tele/inverter_{model}/SENSOR" # Variable {model} will be replaced with model number
|
||||
# username:
|
||||
# password:
|
||||
ha_discovery: True # Home Assistant Discovery, False by default
|
||||
ha_topics: # Name in HA, from Register
|
||||
- name: "Daily Generation"
|
||||
register: daily_power_yields
|
||||
unit: kWh
|
||||
dev_class: energy
|
||||
state_class: total_increasing
|
||||
- name: "Active Power"
|
||||
register: total_active_power
|
||||
unit: W
|
||||
dev_class: power
|
||||
state_class: measurement
|
||||
- name: "Load Power"
|
||||
register: load_power
|
||||
unit: W
|
||||
dev_class: power
|
||||
state_class: measurement
|
||||
- name: "Meter Power"
|
||||
register: meter_power
|
||||
unit: W
|
||||
dev_class: power
|
||||
state_class: measurement
|
||||
- name: "Export to Grid"
|
||||
register: export_to_grid
|
||||
unit: W
|
||||
dev_class: power
|
||||
state_class: measurement
|
||||
- name: "Import from Grid"
|
||||
register: import_from_grid
|
||||
unit: W
|
||||
dev_class: power
|
||||
state_class: measurement
|
||||
- name: "Temperature"
|
||||
register: internal_temperature
|
||||
unit: °C
|
||||
dev_class: temperature
|
||||
state_class: measurement
|
||||
|
||||
- name: pvoutput # Publish Registers to PVOutput
|
||||
api: "xxxxx"
|
||||
sid: "xxxxx"
|
||||
# 60 for regular accounts, 300 for donation accounts
|
||||
rate_limit: 60
|
||||
enabled: True
|
||||
parameters:
|
||||
- name: v1 # Energy Generation
|
||||
register: daily_power_yields # Solar Generated Today (Energy)
|
||||
multiple: 1000
|
||||
- name: v2 # Power Generation
|
||||
register: total_active_power # Current Generation (Power)
|
||||
# - name: v3 # Energy Consumption
|
||||
# register:
|
||||
- name: v4 # Power Consumption
|
||||
register: load_power # Current Home usage (Power)
|
||||
- name: v5 # Temperature
|
||||
register: internal_temperature # Inverter internal temperature
|
||||
# - name: v6 # Voltage
|
||||
# register: total_active_power
|
||||
- name: c1 # Cumulative Flag,
|
||||
value: 1 # If using v2/v4 set to 1
|
||||
# - name: "n" # Net Flag
|
||||
# value: 1
|
||||
# - name: "v7" # Extended Value v7 - Donation Only
|
||||
# register:
|
||||
# - name: "v8" # Extended Value v8 - Donation Only
|
||||
# register:
|
||||
# - name: "v9" # Extended Value v9 - Donation Only
|
||||
# register:
|
||||
# - name: "v10" # Extended Value v10 - Donation Only
|
||||
# register:
|
||||
# - name: "v11" # Extended Value v11 - Donation Only
|
||||
# register:
|
||||
# - name: "v12" # Extended Value v12 - Donation Only
|
||||
# register:
|
||||
# - name: "m1" # Text Message 1 - Donation Only
|
||||
# register:
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import logging
|
||||
|
||||
class export_console(object):
|
||||
def __init__(self):
|
||||
return
|
||||
|
||||
# Configure MQTT
|
||||
def configure(self, config):
|
||||
logging.info("Configured Console Logging")
|
||||
|
||||
def publish(self, inverter):
|
||||
|
||||
print("{:<25} {:<25}".format('Register','Value'))
|
||||
for register in inverter:
|
||||
print("{:<25} {:<25}".format(register,str(inverter.get(register))))
|
||||
|
||||
print(f"Logged {len(inverter)} registers to Console")
|
||||
|
||||
return
|
||||
@@ -0,0 +1,64 @@
|
||||
import logging
|
||||
import json
|
||||
import paho.mqtt.client as mqtt
|
||||
|
||||
class export_mqtt(object):
|
||||
def __init__(self):
|
||||
self.mqtt_client = None
|
||||
self.sensor_topic = None
|
||||
self.ha_discovery = False
|
||||
self.ha_topics = []
|
||||
self.update_model = True
|
||||
|
||||
# Configure MQTT
|
||||
def configure(self, config):
|
||||
self.mqtt_client = mqtt.Client("pv_data")
|
||||
|
||||
if config.get('username') and config.get('password'):
|
||||
self.mqtt_client.username_pw_set(config.get('username'), config.get('password'))
|
||||
|
||||
if config.get('port') == 8883:
|
||||
self.mqtt_client.tls_set()
|
||||
|
||||
self.mqtt_client.connect(config.get('host'), port=config.get('port', 1883))
|
||||
|
||||
self.sensor_topic = config.get('topic', 'tele/inverter_{model}/SENSOR')
|
||||
self.ha_discovery = config.get('ha_discovery', False)
|
||||
|
||||
if self.ha_discovery:
|
||||
for ha_topic in config.get('ha_topics'):
|
||||
self.ha_topics.append(ha_topic)
|
||||
|
||||
logging.info("Configured MQTT Client")
|
||||
|
||||
def publish(self, inverter):
|
||||
global mqtt_client
|
||||
|
||||
if (self.update_model):
|
||||
self.sensor_topic = self.sensor_topic.replace('{model}', inverter.get('device_type_code', 'unknown').replace('.',''))
|
||||
self.update_model = False
|
||||
|
||||
if self.ha_discovery:
|
||||
self.mqtt_client.reconnect()
|
||||
logging.info("Publishing Home Assistant Discovery messages")
|
||||
discovery_topic = 'homeassistant/sensor/inverter/{}/config'
|
||||
discovery_payload = '{{"name":"Inverter {}", "uniq_id":"{}", "stat_t":"{}", "json_attr_t":"{}", "unit_of_meas":"{}", "dev_cla":"{}", "state_class":"{}", "val_tpl":"{{{{ value_json.{} }}}}", "ic":"mdi:solar-power", "device":{{ "name":"Solar Inverter", "mf":"Sungrow", "mdl":"{}", "connections":[["address", "' + self.mqtt_client._host + '" ]]}}}}'
|
||||
|
||||
for ha_topic in self.ha_topics:
|
||||
msg = discovery_payload.format( ha_topic.get('name'), "inverter_" + ha_topic.get('name').lower().replace(' ','_'), self.sensor_topic, self.sensor_topic, ha_topic.get('unit'), ha_topic.get('dev_class'), ha_topic.get('state_class'), ha_topic.get('register'), inverter.get('device_type_code', 'unknown').replace('.',''))
|
||||
result = self.mqtt_client.publish(discovery_topic.format(ha_topic.get('name').lower().replace(' ','_')), msg)
|
||||
result.wait_for_publish()
|
||||
self.ha_discovery = False
|
||||
|
||||
# After a while you'll need to reconnect, so just reconnect before each publish
|
||||
self.mqtt_client.reconnect()
|
||||
result = self.mqtt_client.publish(self.sensor_topic, json.dumps(inverter).replace('"', '\"'))
|
||||
result.wait_for_publish()
|
||||
|
||||
if result.rc != mqtt.MQTT_ERR_SUCCESS:
|
||||
# See https://github.com/eclipse/paho.mqtt.python/blob/master/src/paho/mqtt/client.py#L149 for error code mapping
|
||||
logging.error(f"Failed to publish to MQTT with error code: {result.rc}")
|
||||
else:
|
||||
logging.info("Published to MQTT")
|
||||
|
||||
return result
|
||||
@@ -0,0 +1,104 @@
|
||||
import logging
|
||||
import datetime
|
||||
import requests
|
||||
|
||||
# Configure PVOutput
|
||||
class export_pvoutput(object):
|
||||
def __init__(self):
|
||||
self.api_key = None
|
||||
self.system_id = None
|
||||
self.status_url = "https://pvoutput.org/service/r2/addstatus.jsp"
|
||||
self.metric_mappings = None
|
||||
self.rate_limit = None
|
||||
self.parameters = []
|
||||
self.latest_run = None
|
||||
|
||||
@property
|
||||
def headers(self):
|
||||
return {
|
||||
"X-Pvoutput-Apikey": self.api_key,
|
||||
"X-Pvoutput-SystemId": self.system_id,
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
"cache-control": "no-cache",
|
||||
}
|
||||
|
||||
def configure(self, config):
|
||||
self.api_key = config.get('api')
|
||||
self.system_id = config.get('sid')
|
||||
self.rate_limit = config.get('rate_limit', 60)
|
||||
|
||||
for parameter in config.get('parameters'):
|
||||
self.parameters.append(parameter)
|
||||
|
||||
logging.info("Configured PVOutput Client")
|
||||
|
||||
|
||||
|
||||
def publish(self, inverter):
|
||||
"""
|
||||
See: https://pvoutput.org/help/api_specification.html#add-status-service
|
||||
Parameter Field Required Format Unit Example Donation
|
||||
d Output Date Yes yyyymmdd date 20210228
|
||||
t Time Yes hh:mm time 14:00
|
||||
v1 Energy Generation No number wh 10000
|
||||
v2 Power Generation No number watts 2000
|
||||
v3 Energy Consumption No number wh 10000
|
||||
v4 Power Consumption No number watts 2000
|
||||
# At least one of the values v1, v2, v3 or v4 must be present.
|
||||
v5 Temperature No decimal celsius 23.4
|
||||
v6 Voltage No decimal volts 239.2
|
||||
c1 Cumulative Flag No number 1
|
||||
# 1 - Both v1 and v3 values are lifetime energy values. Consumption and generation energy is reset to 0 at the start of the day.
|
||||
# 2 - Only v1 generation is a lifetime energy value.
|
||||
# 3 - Only v3 consumption is a lifetime energy value.
|
||||
n Net Flag No number 1
|
||||
# n parameter when set to 1 will indicate that the power values passed are net export/import rather than gross generation/consumption. This option is used for devices that are unable to report gross consumption data. The provided import/export data is merged with existing generation data to derive consumption.
|
||||
v7 Extended Value v7 No number User Defined Yes
|
||||
v8 Extended Value v8 No number User Defined Yes
|
||||
v9 Extended Value v9 No number User Defined Yes
|
||||
v10 Extended Value v10 No number User Defined Yes
|
||||
v11 Extended Value v11 No number User Defined Yes
|
||||
v12 Extended Value v12 No number User Defined Yes
|
||||
m1 Text Message 1 No text 30 chars max Yes
|
||||
"""
|
||||
at_least_one_of = set(["v1", "v2", "v3", "v4"])
|
||||
|
||||
now = datetime.datetime.now()
|
||||
|
||||
if self.latest_run:
|
||||
# Spread out our publishes over the hour based on the rate limit
|
||||
time_diff = (now - self.latest_run).total_seconds()
|
||||
if time_diff < (3600 / self.rate_limit):
|
||||
return "skipped"
|
||||
|
||||
payload = {
|
||||
"d": now.strftime("%Y%m%d"),
|
||||
"t": now.strftime("%H:%M"),
|
||||
}
|
||||
|
||||
value_present = False
|
||||
for parameter in self.parameters:
|
||||
if parameter.get('name') == 'v1' or parameter.get('name') == 'v2' or parameter.get('name') == 'v3' or parameter.get('name') == 'v4':
|
||||
value_present = True
|
||||
if parameter.get('register'):
|
||||
if parameter.get('multiple'):
|
||||
payload.update({parameter.get('name'): str(inverter.get(parameter.get('register')) * parameter.get('multiple'))})
|
||||
else:
|
||||
payload.update({parameter.get('name'): str(inverter.get(parameter.get('register')))})
|
||||
elif parameter.get('value'):
|
||||
payload.update({parameter.get('name'): parameter.get('value')})
|
||||
|
||||
if not value_present:
|
||||
logging.error("PVOutput mapping failed, please review metric names and update")
|
||||
return False
|
||||
|
||||
logging.debug("PVOutput Request: " + self.status_url + ", " + str(self.headers) + " : " + str(payload))
|
||||
response = requests.post(url=self.status_url, headers=self.headers, params=payload)
|
||||
|
||||
if response.status_code != requests.codes.ok:
|
||||
raise RuntimeError(response.text)
|
||||
|
||||
logging.info("Published to PVOutput")
|
||||
self.latest_run = now
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,261 @@
|
||||
#!/usr/bin/python3
|
||||
|
||||
from SungrowModbusTcpClient import SungrowModbusTcpClient
|
||||
from SungrowModbusWebClient import SungrowModbusWebClient
|
||||
from pymodbus.client.sync import ModbusTcpClient
|
||||
from threading import Thread
|
||||
from version import __version__
|
||||
|
||||
import importlib
|
||||
import logging
|
||||
import sys
|
||||
import yaml
|
||||
import time
|
||||
|
||||
logging.basicConfig(
|
||||
format='%(asctime)s %(levelname)-8s %(message)s',
|
||||
level=20,
|
||||
datefmt='%Y-%m-%d %H:%M:%S')
|
||||
logging.info(f'Starting SunGather {__version__}')
|
||||
|
||||
try:
|
||||
registers = yaml.safe_load(open('registers.yaml'))
|
||||
logging.info(f"Loaded registers: registers.yaml")
|
||||
except Exception as err:
|
||||
logging.error(f"Failed: Loading registers: registers.yaml {err}")
|
||||
sys.exit(1)
|
||||
|
||||
try:
|
||||
config = yaml.safe_load(open('config.yaml'))
|
||||
logging.info(f"Loaded config: config.yaml")
|
||||
except Exception as err:
|
||||
logging.error(f"Failed: Loading config: config.yaml {err}")
|
||||
sys.exit(1)
|
||||
|
||||
logging.getLogger().setLevel(config['inverter'].get('logging',30))
|
||||
|
||||
exports = []
|
||||
if config.get('exports'):
|
||||
for export in config.get('exports'):
|
||||
try:
|
||||
if export.get('enabled', True):
|
||||
export_load = importlib.import_module("exports." + export.get('name'))
|
||||
logging.info(f"Loaded Export: exports\{export.get('name')}")
|
||||
exports.append(getattr(export_load, "export_" + export.get('name'))())
|
||||
exports[-1].configure(export)
|
||||
logging.info(f"Configured export: {export.get('name')}")
|
||||
except Exception as err:
|
||||
logging.error(f"Failed loading export: {err}" +
|
||||
f"\n\t\t\t Please make sure {export.get('name')}.py exists in the exports folder")
|
||||
|
||||
|
||||
|
||||
client_payload = {
|
||||
"host": config['inverter'].get('host', '127.0.0.1'),
|
||||
"port": config['inverter'].get('port', 502),
|
||||
"timeout": config['inverter'].get('timeout', 10),
|
||||
"retries": config['inverter'].get('retries', 3),
|
||||
"RetryOnEmpty": config['inverter'].get('RetryOnEmpty', False),
|
||||
}
|
||||
|
||||
if config['inverter'].get("connection") == "http":
|
||||
client_payload['port'] = config['inverter'].get('port', '8082')
|
||||
client = SungrowModbusWebClient.SungrowModbusWebClient(**client_payload)
|
||||
elif config['inverter'].get("connection") == "sungrow":
|
||||
client = SungrowModbusTcpClient.SungrowModbusTcpClient(**client_payload)
|
||||
elif config['inverter'].get("connection") == "modbus":
|
||||
client = ModbusTcpClient(**client_payload)
|
||||
else:
|
||||
logging.warning(f'inverter > connection not specified, defaulting to ModbusTcpClient')
|
||||
client = ModbusTcpClient(**client_payload)
|
||||
logging.info("Connection: " + str(client))
|
||||
|
||||
client.connect()
|
||||
client.close()
|
||||
|
||||
def load_registers(register_type, start, count=100):
|
||||
try:
|
||||
logging.debug(f'load_registers: {register_type}, {start}:{count}')
|
||||
if register_type == "read":
|
||||
rr = client.read_input_registers(start,count=count, unit=config['inverter'].get('slave', 0x01))
|
||||
elif register_type == "hold":
|
||||
rr = client.read_holding_registers(start,count=count, unit=config['inverter'].get('slave', 0x01))
|
||||
else:
|
||||
raise RuntimeError(f"Unsupported register type: {type}")
|
||||
except Exception as err:
|
||||
logging.warning(f'No data. Try increasing the timeout or scan interval. {err}')
|
||||
return True
|
||||
|
||||
if rr.isError():
|
||||
logging.warning("Modbus connection failed")
|
||||
return False
|
||||
|
||||
if not hasattr(rr, 'registers'):
|
||||
logging.warning("No registers returned")
|
||||
return
|
||||
|
||||
if len(rr.registers) != count:
|
||||
logging.warning(f"Mismatched number of registers read {len(rr.registers)} != {count}")
|
||||
return
|
||||
|
||||
for num in range(0, count):
|
||||
run = int(start) + num + 1
|
||||
|
||||
for register in registers['registers'][0]['read']:
|
||||
if register_type == "read" and register['address'] == run:
|
||||
register_name = register['name']
|
||||
|
||||
# We convert a system response to a human value
|
||||
register_value = None
|
||||
if register.get('datarange'):
|
||||
for value in register.get('datarange'):
|
||||
if value['response'] == rr.registers[num]:
|
||||
register_value = value['value']
|
||||
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:
|
||||
register_value = 0
|
||||
elif (register.get('datatype') == 'U16' or register.get('datatype') == 'S32') and register_value == 65535:
|
||||
register_value = 0
|
||||
|
||||
if register.get('multiple'):
|
||||
register_value = round(register_value * register.get('multiple'),2)
|
||||
|
||||
# Set the final register name and value after checking model, any adjustments above included
|
||||
if register.get('level',3) <= config['inverter'].get('level',1) or config['inverter'].get('level',1) == 3:
|
||||
if register.get('smart_meter') and config['inverter'].get('smart_meter'):
|
||||
inverter[register_name] = register_value
|
||||
elif register.get('models') and not config['inverter'].get('level',1) == 3:
|
||||
for model in register.get('models'):
|
||||
if model == config['inverter'].get('model'):
|
||||
inverter[register_name] = register_value
|
||||
else:
|
||||
inverter[register_name] = register_value
|
||||
|
||||
for register in registers['registers'][1]['hold']:
|
||||
if register_type == "hold" and register['address'] == run:
|
||||
register_name = register['name']
|
||||
# We convert a system response to a human value
|
||||
register_value = None
|
||||
if register.get('datarange'):
|
||||
for value in register.get('datarange'):
|
||||
if value['response'] == rr.registers[num]:
|
||||
register_value = value['value']
|
||||
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:
|
||||
register_value = 0
|
||||
elif (register.get('datatype') == 'U16' or register.get('datatype') == 'S32') and register_value == 65535:
|
||||
register_value = 0
|
||||
|
||||
if register.get('multiple'):
|
||||
register_value = round(register_value * register.get('multiple'),2)
|
||||
|
||||
# Set the final register name and value after checking model, any adjustments above included
|
||||
if register.get('level',3) <= config['inverter'].get('level',1) or config['inverter'].get('level',1) == 3:
|
||||
if register.get('smart_meter') and config['inverter'].get('smart_meter'):
|
||||
inverter[register_name] = register_value
|
||||
elif register.get('models') and not config['inverter'].get('level',1) == 3:
|
||||
for model in register.get('models'):
|
||||
if model == config['inverter'].get('model'):
|
||||
inverter[register_name] = register_value
|
||||
else:
|
||||
inverter[register_name] = register_value
|
||||
|
||||
return True
|
||||
|
||||
# Inverter Scanning
|
||||
inverter = {}
|
||||
model = None
|
||||
|
||||
# Core monitoring loop
|
||||
def scrape_inverter():
|
||||
""" Connect to the inverter and scrape the metrics """
|
||||
client.connect()
|
||||
|
||||
for scan in registers['scan']:
|
||||
if scan.get('read'):
|
||||
for subscan in registers['scan'][0]['read']:
|
||||
if subscan.get('level',3) <= config['inverter'].get('level',1) or config['inverter'].get('level',1) == 3:
|
||||
logging.debug(f'Scanning: read, {subscan.get("start")}:{subscan.get("range")}')
|
||||
if not load_registers("read", int(subscan.get('start')), int(subscan.get('range'))):
|
||||
return False
|
||||
if scan.get('hold'):
|
||||
for subscan in registers['scan'][1]['hold']:
|
||||
if subscan.get('level',3) <= config['inverter'].get('level',1) or config['inverter'].get('level',1) == 3:
|
||||
logging.debug(f'Scanning: hold, {subscan.get("start")}:{subscan.get("range")}')
|
||||
if not load_registers("hold", int(subscan.get('start')), int(subscan.get('range'))):
|
||||
return False
|
||||
|
||||
# Create a registers for Power imported and exported to/from Grid
|
||||
if config['inverter'].get('level',1) >= 1:
|
||||
try:
|
||||
inverter["export_to_grid"] = 0
|
||||
inverter["import_from_grid"] = 0
|
||||
power = inverter.get('meter_power', inverter.get('export_power', 0))
|
||||
if power < 0:
|
||||
inverter["export_to_grid"] = abs(power)
|
||||
elif power >= 0:
|
||||
inverter["import_from_grid"] = power
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
inverter["timestamp"] = "%s-%s-%s %s:%02d:%02d" % (
|
||||
inverter["year"],
|
||||
inverter["month"],
|
||||
inverter["day"],
|
||||
inverter["hour"],
|
||||
inverter["minute"],
|
||||
inverter["second"],
|
||||
)
|
||||
del inverter["year"]
|
||||
del inverter["month"]
|
||||
del inverter["day"]
|
||||
del inverter["hour"]
|
||||
del inverter["minute"]
|
||||
del inverter["second"]
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
client.close()
|
||||
return True
|
||||
|
||||
if load_registers("read", 4999, 1):
|
||||
model = inverter.get('device_type_code')
|
||||
logging.info(f'Detected Model: {model}')
|
||||
if not config['inverter'].get('model'):
|
||||
config['inverter']['model'] = model
|
||||
elif not model == config['inverter'].get('model'):
|
||||
logging.warn(f'Model specified in config {config["inverter"].get("model")} does not match model reported by inverter {model}')
|
||||
|
||||
while True:
|
||||
# Scrape the inverter
|
||||
success = scrape_inverter()
|
||||
|
||||
for export in exports:
|
||||
t = Thread(target=export.publish, args=(inverter,))
|
||||
t.start()
|
||||
|
||||
# if args.one_shot:
|
||||
# logging.info("Exiting due to --one-shot")
|
||||
# break
|
||||
|
||||
# Sleep until the next scan
|
||||
time.sleep(config['inverter'].get('scan_interval', 30))
|
||||
@@ -0,0 +1 @@
|
||||
__version__ = '0.1.0'
|
||||
@@ -0,0 +1,5 @@
|
||||
paho-mqtt>=1.5.1
|
||||
pymodbus>=2.4.0
|
||||
SungrowModbusTcpClient>=0.1.5
|
||||
SungrowModbusWebClient>=0.2.4
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import setuptools
|
||||
|
||||
exec(open('SunGather/version.py').read())
|
||||
|
||||
with open("README.md", "r") as fh:
|
||||
long_description = fh.read()
|
||||
|
||||
setuptools.setup(
|
||||
name="SunGather",
|
||||
version=__version__,
|
||||
author="Bohdan Flower",
|
||||
author_email="github@bohdan.net",
|
||||
description="Collect data from Sungrow Inverters and feed to various locations (MQTT, PVOutput, Home Assistant)",
|
||||
long_description=long_description,
|
||||
long_description_content_type="text/markdown",
|
||||
url="https://github.com/bohdan-s/SunGather",
|
||||
packages=setuptools.find_packages(),
|
||||
install_requires=[
|
||||
'pymodbus>=2.3.0',
|
||||
'websocket-client>=1.2.1',
|
||||
],
|
||||
classifiers=[
|
||||
"Programming Language :: Python :: 3",
|
||||
"License :: OSI Approved :: GNU General Public License v3 (GPLv3)",
|
||||
"Operating System :: OS Independent",
|
||||
],
|
||||
python_requires='>=3.5.0',
|
||||
)
|
||||
Reference in New Issue
Block a user