diff --git a/.gitignore b/.gitignore index 510c73d..658570a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ +config.yaml + # Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] diff --git a/SunGather/README.md b/SunGather/README.md new file mode 100644 index 0000000..3cfd126 --- /dev/null +++ b/SunGather/README.md @@ -0,0 +1,124 @@ +
+ +[![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] + +
+
+ +

SunGather

+ +

+ Collect data from Sungrow Inverters using ModbusTcpClient, SungrowModbusTcpClient or SungrowModbusWebClient and export to various locations. +
+
+ Report Bug + · + Request Feature +

+
+ + +## 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: Communication Protocol of PV Grid-Connected String Inverters + +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) + +

(back to top)

+ +### 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/) + +

(back to top)

+ + +## 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 +``` + +

(back to top)

+ + + + +## Usage + +See config-exmaple.py it contains default options and comments. + + +

(back to top)

+ + +## Tested +* SG7.0RT with WiNet-S Dongle + +

(back to top)

+ + + +## License + +Distributed under the GPL3 License. See `LICENSE.txt` for more information. + +

(back to top)

+ + + +## Contact + +Project Link: [https://github.com/bohdan-s/SungrowModbusWebClient](https://github.com/bohdan-s/SungrowModbusWebClient) + +

(back to top)

+ + + +## Acknowledgments + +* [solariot](https://github.com/meltaxa/solariot) +* [modbus4mqtt](https://github.com/tjhowse/modbus4mqtt) +* [ModbusTCP2MQTT](https://github.com/TenySmart/ModbusTCP2MQTT) + +

(back to top)

+ + + + + +[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 \ No newline at end of file diff --git a/SunGather/__init__.py b/SunGather/__init__.py new file mode 100644 index 0000000..7152555 --- /dev/null +++ b/SunGather/__init__.py @@ -0,0 +1 @@ +from .version import __version__ \ No newline at end of file diff --git a/SunGather/config-example.yaml b/SunGather/config-example.yaml new file mode 100644 index 0000000..166ec7f --- /dev/null +++ b/SunGather/config-example.yaml @@ -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: + \ No newline at end of file diff --git a/SunGather/exports/__init__.py b/SunGather/exports/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/SunGather/exports/console.py b/SunGather/exports/console.py new file mode 100644 index 0000000..30ba6d8 --- /dev/null +++ b/SunGather/exports/console.py @@ -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 \ No newline at end of file diff --git a/SunGather/exports/mqtt.py b/SunGather/exports/mqtt.py new file mode 100644 index 0000000..b12d8d5 --- /dev/null +++ b/SunGather/exports/mqtt.py @@ -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 \ No newline at end of file diff --git a/SunGather/exports/pvoutput.py b/SunGather/exports/pvoutput.py new file mode 100644 index 0000000..8110974 --- /dev/null +++ b/SunGather/exports/pvoutput.py @@ -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 + + diff --git a/SunGather/registers.yaml b/SunGather/registers.yaml new file mode 100644 index 0000000..2c77284 --- /dev/null +++ b/SunGather/registers.yaml @@ -0,0 +1,1011 @@ +registers: + - read: + - name: "serial_number" + address: 4990 + datatype: "UTF-8" + - name: "device_type_code" + level: 0 + address: 5000 + datatype: "U16" + datarange: + - response: 0x27 + value: "SG30KTL" + - response: 0x26 + value: "SG10KTL" + - response: 0x29 + value: "SG12KTL" + - response: 0x28 + value: "SG15KTL" + - response: 0x2A + value: "SG20KTL" + - response: 0x2C + value: "SG30KU" + - response: 0x2D + value: "SG36KTL" + - response: 0x2E + value: "SG36KU" + - response: 0x2F + value: "SG40KTL" + - response: 0x0135 + value: "SG40KTL-M" + - response: 0x011B + value: "SG50KTL-M" + - response: 0x0131 + value: "SG60KTL-M" + - response: 0x0136 + value: "SG60KU" + - response: 0x0141 + value: "SG30KTL-M" + - response: 0x70 + value: "SG30KTL-M-V31" + - response: 0x0134 + value: "SG33KTL-M" + - response: 0x74 + value: "SG36KTL-M" + - response: 0x013D + value: "SG33K3J" + - response: 0x0137 + value: "SG49K5J" + - response: 0x72 + value: "SG34KJ" + - response: 0x73 + value: "LP_P34KSG" + - response: 0x011B + value: "SG50KTL-M-20" + - response: 0x010F + value: "SG60KTL" + - response: 0x0138 + value: "SG80KTL" + - response: 0x0138 + value: "SG80KTL-20" + - response: 0x0132 + value: "SG60KU-M" + - response: 0x0147 + value: "SG5KTL-MT" + - response: 0x0148 + value: "SG6KTL-MT" + - response: 0x013F + value: "SG8KTL-M" + - response: 0x013E + value: "SG10KTL-M" + - response: 0x2C0F + value: "SG10KTL-MT" + - response: 0x013C + value: "SG12KTL-M" + - response: 0x0142 + value: "SG15KTL-M" + - response: 0x0149 + value: "SG17KTL-M" + - response: 0x0143 + value: "SG20KTL-M" + - response: 0x0139 + value: "SG80KTL-M" + - response: 0x014C + value: "SG111HV" + - response: 0x013B + value: "SG125HV" + - response: 0x2C03 + value: "SG125HV-20" + - response: 0x2C10 + value: "SG30CX" + - response: 0x2C00 + value: "SG33CX" + - response: 0x2C0A + value: "SG36CX-US" + - response: 0x2C01 + value: "SG40CX" + - response: 0x2C02 + value: "SG50CX" + - response: 0x2C0B + value: "SG60CX-US" + - response: 0x2C06 + value: "SG110CX" + - response: 0x2C0C + value: "SG250HX" + - response: 0x2C11 + value: "SG250HX-US" + - response: 0x2C12 + value: "SG100CX" + - response: 0x2C12 + value: "SG100CX-JP" + - response: 0x2C13 + value: "SG250HX-IN" + - response: 0x2C15 + value: "SG25CX-SA" + - response: 0x2C22 + value: "SG75CX" + - response: 0x243D + value: "SG3.0RT" + - response: 0x243E + value: "SG4.0RT" + - response: 0x2430 + value: "SG5.0RT" + - response: 0x2431 + value: "SG6.0RT" + - response: 0x243C + value: "SG7.0RT" + - response: 0x2432 + value: "SG8.0RT" + - response: 0x2433 + value: "SG10RT" + - response: 0x2434 + value: "SG12RT" + - response: 0x2435 + value: "SG15RT" + - response: 0x2436 + value: "SG17RT" + - response: 0x2437 + value: "SG20RT" + - name: "nominal_active_power" + level: 2 + address: 5001 + datatype: "U16" + multiple: 0.1 + - name: "output_type" + level: 2 + address: 5002 + datatype: "U16" + datarange: + - response: 0 + value: "2P" + - response: 1 + value: "3P4L" + - response: 1 + value: "3PSL" + - name: "daily_power_yields" + level: 0 + address: 5003 + datatype: "U16" + multiple: 0.1 + - name: "total_power_yields" + level: 1 + address: 5004 + datatype: "U32" + - name: "total_running_time" + level: 1 + address: 5006 + datatype: "U32" + - name: "internal_temperature" + level: 1 + address: 5008 + datatype: "S16" + multiple: 0.1 + - name: "total_apparent_power" + level: 2 + address: 5009 + datatype: "U32" + models: ["SG6KTL-MT", "SG8KTL-M", "SG10KTL-M", "SG10KTL-MT", "SG12KTL-M", "SG15KTL-M", "SG17KTL-M", "SG20KTL-M", "SG3.0RT", "SG4.0RT", "SG5.0RT", "SG6.0RT", "SG7.0RT", "SG8.0RT", "SG10RT", "SG12RT", "SG15RT", "SG17RT", "SG20RT", "SG33K3J", "SG36KTL-M", "SG40KTL-M", "SG50KTL", "SG50KTL-M", "SG60KTL", "SG60KTL-M", "SG60KU-M", "SG80KTL", "SG80KTL-M", "SG111HV", "SG125HV", "SG125HV-20", "SG33CX", "SG40CX", "SG50CX", "SG110CX", "SG250HX", "SG30CX", "SG36CX-US", "SG60CX-US", "SG250HX-US", "SG250HX-IN", "SG25CX-SA", "SG100CX", "SG75CX", "SG225HX"] + - name: "mppt_1_voltage" + level: 2 + address: 5011 + datatype: "U16" + multiple: 0.1 + - name: "mppt_1_current" + level: 2 + address: 5012 + datatype: "U16" + multiple: 0.1 + - name: "mppt_2_voltage" + level: 2 + address: 5013 + datatype: "U16" + multiple: 0.1 + models: ["SG30KTL","SG10KTL","SG12KTL","SG15KTL","SG20KTL","SG30KU","SG36KTL","SG36KU","SG40KTL","SG40KTL-M","SG50KTL-M","SG60KTL-M","SG30KTL-M","SG30KTL-M-V31","SG33KTL-M","SG36KTL-M","SG33K3J","SG49K5J","SG34KJ","SG50KTL-M-20","SG60KU-M","SG5KTL-MT","SG6KTL-MT","SG8KTL-M","SG10KTL-M","SG10KTL-MT","SG12KTL-M","SG15KTL-M","SG17KTL-M","SG20KTL-M","SG80KTL-M","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"] + - name: "mppt_2_current" + level: 2 + address: 5014 + datatype: "U16" + multiple: 0.1 + models: ["SG30KTL","SG10KTL","SG12KTL","SG15KTL","SG20KTL","SG30KU","SG36KTL","SG36KU","SG40KTL","SG40KTL-M","SG50KTL-M","SG60KTL-M","SG30KTL-M","SG30KTL-M-V31","SG33KTL-M","SG36KTL-M","SG33K3J","SG49K5J","SG34KJ","SG50KTL-M-20","SG60KU-M","SG5KTL-MT","SG6KTL-MT","SG8KTL-M","SG10KTL-M","SG10KTL-MT","SG12KTL-M","SG15KTL-M","SG17KTL-M","SG20KTL-M","SG80KTL-M","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"] + - name: "mppt_3_voltage" + level: 2 + address: 5015 + datatype: "U16" + multiple: 0.1 + models: ["SG40KTL-M","SG50KTL-M","SG60KTL-M","SG30KTL-M","SG30KTL-M-V31","SG33KTL-M","SG36KTL-M","SG33K3J","SG49K5J","SG50KTL-M-20","SG60KU-M","SG80KTL-M","SG30CX","SG33CX","SG36CX-US","SG40CX","SG50CX","SG60CX-US","SG110CX","SG250HX","SG250HX-US","SG100CX","SG100CX-JP","SG250HX-IN","SG25CX-SA","SG75CX"] + - name: "mppt_3_current" + level: 2 + address: 5016 + datatype: "U16" + multiple: 0.1 + models: ["SG40KTL-M","SG50KTL-M","SG60KTL-M","SG30KTL-M","SG30KTL-M-V31","SG33KTL-M","SG36KTL-M","SG33K3J","SG49K5J","SG50KTL-M-20","SG60KU-M","SG80KTL-M","SG30CX","SG33CX","SG36CX-US","SG40CX","SG50CX","SG60CX-US","SG110CX","SG250HX","SG250HX-US","SG100CX","SG100CX-JP","SG250HX-IN","SG25CX-SA","SG75CX"] + - name: "total_dc_power" + level: 2 + address: 5017 + datatype: "S32" # Documentation says Unsinged, but seems to be returning Signed 32bit + - name: "phase_a_voltage" + level: 2 + address: 5019 + datatype: "U16" + multiple: 0.1 + - name: "phase_b_voltage" + level: 2 + address: 5020 + datatype: "U16" + multiple: 0.1 + - name: "phase_c_voltage" + level: 2 + address: 5021 + datatype: "U16" + multiple: 0.1 + - name: "phase_a_current" + level: 2 + address: 5022 + datatype: "S16" # Documentation says Unsinged, but seems to be returning Signed 16bit + multiple: 0.1 + - name: "phase_b_current" + level: 2 + address: 5023 + datatype: "S16" # Documentation says Unsinged, but seems to be returning Signed 16bit + multiple: 0.1 + - name: "phase_c_current" + level: 2 + address: 5024 + datatype: "S16" # Documentation says Unsinged, but seems to be returning Signed 16bit + multiple: 0.1 +# - name: "Reserved" +# address: 5025 +# datatype: "U32" +# - name: "Reserved" +# address: 5027 +# datatype: "U32" +# - name: "Reserved" +# address: 5029 +# datatype: "U32" + - name: "total_active_power" + level: 0 + address: 5031 + datatype: "U32" + - name: "total_reactive_power" + level: 2 + address: 5033 + datatype: "S32" + - name: "power_factor" # >0 means leading, <0 means lagging + level: 2 + address: 5035 + datatype: "S16" + multiple: 0.001 + - name: "grid_frequency" + level: 2 + address: 5036 + datatype: "U16" + multiple: 0.1 +# - name: "Reserved" +# address: 5037 +# datatype: "U16" + - name: "work_state_1" # See Appendix 1 + level: 2 + address: 5038 + datatype: "U16" + datarange: + - response: 0x0 + value: "Run" + - response: 0x8000 + value: "Stop" + - response: 0x1300 + value: "Key Stop" + - response: 0x1500 + value: "Emergancy Stop" + - response: 0x1400 + value: "Standby" + - response: 0x1200 + value: "Initial Stanby" + - response: 0x1600 + value: "Starting" + - response: 0x9100 + value: "Alarm Run" + - response: 0x8100 + value: "Derating Run" + - response: 0x8200 + value: "Dispatch Run" + - response: 0x5500 + value: "Fault" + - response: 0x2500 + value: "Communication Fault" + - name: "alarm_time_year" + level: 3 + address: 5039 + datatype: "U16" + - name: "alarm_time_month" + level: 3 + address: 5040 + datatype: "U16" + - name: "alarm_time_day" + level: 3 + address: 5041 + datatype: "U16" + - name: "alarm_time_hour" + level: 3 + address: 5042 + datatype: "U16" + - name: "alarm_time_minute" + level: 3 + address: 5043 + datatype: "U16" + - name: "alarm_time_second" + level: 3 + address: 5045 + datatype: "U16" + - name: "alarm_code_1" # See Appendix 3 + level: 3 + address: 5045 + datatype: "U16" + - name: "Reserved" + address: 5046 - 5048 + datatype: "U16" + - name: "nominal_reactive_power" + level: 2 + address: 5049 + datatype: "U16" + multiple: 0.1 +# - name: "Reserved" +# address: 5050 - 5070 +# datatype: "U32" + - name: "array_insulation_resistance" + level: 2 + address: 5071 + datatype: "U16" +# - name: "Reserved" +# address: 5072 +# datatype: "U16" +# - name: "Reserved" +# address: 5073 - 5076 + - name: "active_power_regulation_setpoint" + level: 2 + address: 5077 + datatype: "U32" + - name: "reactive_power_regulation_setpoint" + level: 2 + address: 5079 + datatype: "S32" + - name: "work_state_2" # See Appendix 2 + level: 2 + address: 5081 + datatype: "U32" + datarange: + - response: 0 + value: "Run" + - response: 1 + value: "Stop" + - response: 3 + value: "Key Stop" + - response: 5 + value: "Emergancy Stop" + - response: 4 + value: "Standby" + - response: 2 + value: "Initial Stanby" + - response: 6 + value: "Starting" + - response: 10 + value: "Alarm Run" + - response: 11 + value: "Derating Run" + - response: 12 + value: "Dispatch Run" + - response: 9 + value: "Fault" + - response: 13 + value: "Communication Fault" + - response: 17 + value: "Total Run Bit" + - response: 18 + value: "Total Fault Bit" + - name: "meter_power" + level: 1 + address: 5083 + datatype: "S32" + indicator: True + smart_meter: True + models: ["SG5KTL-MT","SG6KTL-MT","SG8KTL-M","SG10KTL-M","SG10KTL-MT","SG12KTL-M","SG15KTL-M","SG17KTL-M","SG20KTL-M"] # This returns data on my SG7.0RT, maybe due to additional DTSU666? + - name: "meter_a_phase_power" + level: 2 + address: 5085 + datatype: "S32" + models: ["SG5KTL-MT","SG6KTL-MT","SG8KTL-M","SG10KTL-M","SG10KTL-MT","SG12KTL-M","SG15KTL-M","SG17KTL-M","SG20KTL-M"] + - name: "meter_b_phase_power" + level: 2 + address: 5087 + datatype: "S32" + models: ["SG5KTL-MT","SG6KTL-MT","SG8KTL-M","SG10KTL-M","SG10KTL-MT","SG12KTL-M","SG15KTL-M","SG17KTL-M","SG20KTL-M"] + - name: "meter_c_phase_power" + level: 2 + address: 5089 + datatype: "S32" + models: ["SG5KTL-MT","SG6KTL-MT","SG8KTL-M","SG10KTL-M","SG10KTL-MT","SG12KTL-M","SG15KTL-M","SG17KTL-M","SG20KTL-M"] + - name: "load_power" + level: 1 + address: 5091 + datatype: "S32" + smart_meter: True + models: ["SG5KTL-MT","SG6KTL-MT","SG8KTL-M","SG10KTL-M","SG10KTL-MT","SG12KTL-M","SG15KTL-M","SG17KTL-M","SG20KTL-M"] + - name: "daily_export_energy" + level: 1 + address: 5093 + datatype: "U32" + models: ["SG5KTL-MT","SG6KTL-MT","SG8KTL-M","SG10KTL-M","SG10KTL-MT","SG12KTL-M","SG15KTL-M","SG17KTL-M","SG20KTL-M"] + - name: "total_export_energy" + level: 1 + address: 5095 + datatype: "U32" + models: ["SG5KTL-MT","SG6KTL-MT","SG8KTL-M","SG10KTL-M","SG10KTL-MT","SG12KTL-M","SG15KTL-M","SG17KTL-M","SG20KTL-M"] + - name: "daily_import_energy" + level: 1 + address: 5097 + datatype: "U32" + models: ["SG5KTL-MT","SG6KTL-MT","SG8KTL-M","SG10KTL-M","SG10KTL-MT","SG12KTL-M","SG15KTL-M","SG17KTL-M","SG20KTL-M"] + - name: "total_import_energy" + level: 1 + address: 5099 + datatype: "U32" + models: ["SG5KTL-MT","SG6KTL-MT","SG8KTL-M","SG10KTL-M","SG10KTL-MT","SG12KTL-M","SG15KTL-M","SG17KTL-M","SG20KTL-M"] + - name: "daily_direct_energy_consumption" + level: 1 + address: 5101 + datatype: "U32" + models: ["SG5KTL-MT","SG6KTL-MT","SG8KTL-M","SG10KTL-M","SG10KTL-MT","SG12KTL-M","SG15KTL-M","SG17KTL-M","SG20KTL-M"] + - name: "total_direct_energy_consumption" + level: 1 + address: 5103 + datatype: "U32" + models: ["SG5KTL-MT","SG6KTL-MT","SG8KTL-M","SG10KTL-M","SG10KTL-MT","SG12KTL-M","SG15KTL-M","SG17KTL-M","SG20KTL-M"] +# - name: "Reserved" +# address: 5105 - 5112 + - name: "mppt_4_voltage" + level: 2 + address: 5115 + datatype: "U16" + multiple: 0.1 + models: ["SG50KTL-M","SG60KTL-M","SG49K5J","SG50KTL-M-20","SG60KU-M","SG80KTL-M","SG40CX","SG50CX","SG60CX-US","SG110CX","SG250HX","SG250HX-US","SG100CX","SG100CX-JP","SG250HX-IN","SG75CX"] + - name: "mppt_4_current" + level: 2 + address: 5116 + datatype: "U16" + multiple: 0.1 + models: ["SG50KTL-M","SG60KTL-M","SG49K5J","SG50KTL-M-20","SG60KU-M","SG80KTL-M","SG40CX","SG50CX","SG60CX-US","SG110CX","SG250HX","SG250HX-US","SG100CX","SG100CX-JP","SG250HX-IN","SG75CX"] + - name: "mppt_5_voltage" + level: 2 + address: 5117 + datatype: "U16" + multiple: 0.1 + models: ["SG50CX","SG60CX-US","SG110CX","SG250HX","SG250HX-US","SG100CX","SG100CX-JP","SG250HX-IN","SG75CX"] + - name: "mppt_5_current" + level: 2 + address: 5118 + datatype: "U16" + multiple: 0.1 + models: ["SG50CX","SG60CX-US","SG110CX","SG250HX","SG250HX-US","SG100CX","SG100CX-JP","SG250HX-IN","SG75CX"] + - name: "mppt_6_voltage" + level: 2 + address: 5119 + datatype: "U16" + multiple: 0.1 + models: ["SG110CX","SG250HX","SG250HX-US","SG100CX","SG100CX-JP","SG250HX-IN","SG75CX"] + - name: "mppt_6_current" + level: 2 + address: 5120 + datatype: "U16" + multiple: 0.1 + models: ["SG110CX","SG250HX","SG250HX-US","SG100CX","SG100CX-JP","SG250HX-IN","SG75CX"] + - name: "mppt_7_voltage" + level: 2 + address: 5121 + datatype: "U16" + multiple: 0.1 + models: ["SG110CX","SG250HX","SG250HX-US","SG100CX","SG100CX-JP","SG250HX-IN","SG75CX"] + - name: "mppt_7_current" + level: 2 + address: 5122 + datatype: "U16" + multiple: 0.1 + models: ["SG110CX","SG250HX","SG250HX-US","SG100CX","SG100CX-JP","SG250HX-IN","SG75CX"] + - name: "mppt_8_voltage" + level: 2 + address: 5123 + datatype: "U16" + multiple: 0.1 + models: ["SG110CX","SG250HX","SG250HX-US","SG100CX","SG100CX-JP","SG250HX-IN","SG75CX"] + - name: "mppt_8_current" + level: 2 + address: 5124 + datatype: "U16" + multiple: 0.1 + models: ["SG110CX","SG250HX","SG250HX-US","SG100CX","SG100CX-JP","SG250HX-IN","SG75CX"] +# - name: "Reserved" +# address: 5125 +# - name: "Reserved" +# address: 5126 - 5127 + - name: "monthly_power_yields" + level: 1 + address: 5128 + datatype: "U32" + multiple: 0.1 + - name: "mppt_9_voltage" + level: 2 + address: 5130 + datatype: "U16" + multiple: 0.1 + models: ["SG110CX","SG250HX","SG250HX-US","SG100CX","SG100CX-JP","SG250HX-IN","SG75CX"] + - name: "mppt_9_current" + level: 2 + address: 5131 + datatype: "U16" + multiple: 0.1 + models: ["SG110CX","SG250HX","SG250HX-US","SG100CX","SG100CX-JP","SG250HX-IN","SG75CX"] + - name: "mppt_10_voltage" + level: 2 + address: 5132 + datatype: "U16" + multiple: 0.1 + models: ["SG250HX","SG250HX-US","SG100CX","SG100CX-JP","SG250HX-IN"] + - name: "mppt_10_current" + level: 2 + address: 5133 + datatype: "U16" + multiple: 0.1 + models: ["SG250HX","SG250HX-US","SG100CX","SG100CX-JP","SG250HX-IN"] + - name: "mppt_11_voltage" + level: 2 + address: 5134 + datatype: "U16" + multiple: 0.1 + models: ["SG250HX","SG250HX-US","SG100CX","SG100CX-JP","SG250HX-IN"] + - name: "mppt_11_current" + level: 2 + address: 5135 + datatype: "U16" + multiple: 0.1 + models: ["SG250HX","SG250HX-US","SG100CX","SG100CX-JP","SG250HX-IN"] + - name: "mppt_12_voltage" + level: 2 + address: 5136 + datatype: "U16" + multiple: 0.1 + models: ["SG250HX","SG250HX-US","SG100CX","SG100CX-JP","SG250HX-IN"] + - name: "mppt_12_current" + level: 2 + address: 5137 + datatype: "U16" + multiple: 0.1 + models: ["SG250HX","SG250HX-US","SG100CX","SG100CX-JP","SG250HX-IN"] +# - name: "Reserved" +# address: 5138 - 5139 +# - name: "Reserved" +# address: 5140 +# - name: "Reserved" +# address: 5141 +# - name: "Reserved" +# address: 5142 +# - name: "Reserved" +# address: 5143 + - name: "total_power_yields" + level: 1 + address: 5144 + datatype: "U32" + multiple: 0.1 + models: ["SG5KTL-MT","SG6KTL-MT","SG8KTL-M","SG10KTL-M","SG10KTL-MT","SG12KTL-M","SG15KTL-M","SG17KTL-M","SG20KTL-M","SG3.0RT","SG4.0RT","SG5.0RT","SG6.0RT","SG7.0RT","SG8.0RT","SG10RT","SG12RT","SG15RT","SG17RT","SG20RT","SG80KTL-M","SG111HV","SG125HV","SG125HV-20","SG33CX","SG40CX","SG50CX","SG110CX","SG250HX","SG30CX","SG36CX-US","SG60CX-US","SG250HX-US","SG250HX-IN","SG25CX-SA","SG100CX","SG75CX","SG225HX"] + - name: "negative_voltage_to_the_ground" + level: 2 + address: 5146 + datatype: "S16" + multiple: 0.1 + - name: "bus_voltage" + level: 2 + address: 5147 + datatype: "U16" + multiple: 0.1 + - name: "grid_frequency" + level: 2 + address: 5148 + datatype: "U16" + multiple: 0.01 + models: ["SG5KTL-MT","SG6KTL-MT","SG8KTL-M","SG10KTL-M","SG10KTL-MT","SG12KTL-M","SG15KTL-M","SG17KTL-M","SG20KTL-M","SG3.0RT","SG4.0RT","SG5.0RT","SG6.0RT","SG7.0RT","SG8.0RT","SG10RT","SG12RT","SG15RT","SG17RT","SG20RT","SG80KTL-M","SG111HV","SG125HV","SG125HV-20","SG33CX","SG40CX","SG50CX","SG110CX","SG250HX","SG30CX","SG36CX-US","SG60CX-US","SG250HX-US","SG250HX-IN","SG25CX-SA","SG100CX","SG75CX","SG225HX"] +# - name: "Reserved" +# address: 5149 +# datatype: "U16" +# multiple: 0.01 + - name: "pid_work_state" + level: 2 + address: 5150 + datatype: "U16" + datarange: + - response: 2 + value: "PID Recover Operation" + - response: 4 + value: "Anti-PID Operation" + - response: 8 + value: "PID Abnormity" + models: ["SG5KTL-MT","SG6KTL-MT","SG8KTL-M","SG10KTL-M","SG10KTL-MT","SG12KTL-M","SG15KTL-M","SG17KTL-M","SG20KTL-M","SG3.0RT","SG4.0RT","SG5.0RT","SG6.0RT","SG7.0RT","SG8.0RT","SG10RT","SG12RT","SG15RT","SG17RT","SG20RT","SG80KTL-M","SG111HV","SG125HV","SG125HV-20","SG33CX","SG40CX","SG50CX","SG110CX","SG250HX","SG30CX","SG36CX-US","SG60CX-US","SG250HX-US","SG250HX-IN","SG25CX-SA","SG100CX","SG75CX","SG225HX"] + - name: "pid_alarm_code" + level: 2 + address: 5151 + datatype: "U16" + datarange: + - response: 432 + value: "PID resistance abnormal" + - response: 433 + value: "PID function abnormal" + - response: 434 + value: "PID overvoltage/overcurrent protection" +# - name: "Reserved" +# address: 5152 +# datatype: "U16" + # Folling 2 registers are not in the documentation, but other projects are using them + - name: "export_power" + level: 3 + address: 5216 + datatype: "U16" + indicator: True + - name: "power_meter" + level: 3 + address: 5218 + datatype: "U16" + multiple: 0.1 +# - name: "Reserved" +# address: 5153-7012 + - name: "string_1_current" + level: 2 + address: 7013 + datatype: "U16" + multiple: 0.01 + - name: "string_2_current" + level: 2 + address: 7014 + datatype: "U16" + multiple: 0.01 + - name: "string_3_current" + level: 2 + address: 7015 + datatype: "U16" + multiple: 0.01 + - name: "string_4_current" + level: 2 + address: 7016 + datatype: "U16" + multiple: 0.01 + - name: "string_5_current" + level: 2 + address: 7017 + datatype: "U16" + multiple: 0.01 + - name: "string_6_current" + level: 2 + address: 7018 + datatype: "U16" + multiple: 0.01 + - name: "string_7_current" + level: 2 + address: 7019 + datatype: "U16" + multiple: 0.01 + - name: "string_8_current" + level: 2 + address: 7020 + datatype: "U16" + multiple: 0.01 + - name: "string_9_current" + level: 2 + address: 7021 + datatype: "U16" + multiple: 0.01 + - name: "string_10_current" + level: 2 + address: 7022 + datatype: "U16" + multiple: 0.01 + - name: "string_11_current" + level: 2 + address: 7023 + datatype: "U16" + multiple: 0.01 + - name: "string_12_current" + level: 2 + address: 7024 + datatype: "U16" + multiple: 0.01 + - name: "string_13_current" + level: 2 + address: 7025 + datatype: "U16" + multiple: 0.01 + - name: "string_14_current" + level: 2 + address: 7026 + datatype: "U16" + multiple: 0.01 + - name: "string_15_current" + level: 2 + address: 7027 + datatype: "U16" + multiple: 0.01 + - name: "string_16_current" + level: 2 + address: 7028 + datatype: "U16" + multiple: 0.01 + - name: "string_17_current" + level: 2 + address: 7029 + datatype: "U16" + multiple: 0.01 + - name: "string_18_current" + level: 2 + address: 7030 + datatype: "U16" + multiple: 0.01 + - name: "string_19_current" + level: 2 + address: 7031 + datatype: "U16" + multiple: 0.01 + - name: "string_20_current" + level: 2 + address: 7032 + datatype: "U16" + multiple: 0.01 + - name: "string_21_current" + level: 2 + address: 7033 + datatype: "U16" + multiple: 0.01 + - name: "string_22_current" + level: 2 + address: 7034 + datatype: "U16" + multiple: 0.01 + - name: "string_23_current" + level: 2 + address: 7035 + datatype: "U16" + multiple: 0.01 + - name: "string_24_current" + level: 2 + address: 7036 + datatype: "U16" + multiple: 0.01 + - hold: + - name: "year" + level: 0 + address: 5000 + datatype: "U16" + - name: "month" + level: 0 + address: 5001 + datatype: "U16" + - name: "day" + level: 0 + address: 5002 + datatype: "U16" + - name: "hour" + level: 0 + address: 5003 + datatype: "U16" + - name: "hour" + level: 0 + address: 5003 + datatype: "U16" + - name: "minute" + level: 0 + address: 5004 + datatype: "U16" + - name: "second" + level: 0 + address: 5005 + datatype: "U16" + - name: "start_stop" + level: 1 + address: 5006 + datatype: "U16" + datarange: + - response: 0xCF + value: "Start" + - response: 0xCE + value: "Stop" + - name: "power_limitation_switch" + level: 2 + address: 5007 + datatype: "U16" + datarange: + - response: 0xAA + value: "Enable" + - response: 0x55 + value: "Disable" + - name: "power_limitation_setting" + level: 2 + address: 5008 + datatype: "U16" +# - name: "Reserved" +# address: 5009 +# datatype: "U16" +# models: ["SG5KTL-MT","SG6KTL-MT","SG8KTL-M","SG10KTL-M","SG10KTL-MT","SG12KTL-M","SG15KTL-M","SG17KTL-M","SG20KTL-M"] + - name: "export_power_limitation" + level: 2 + address: 5010 + datatype: "U16" + models: ["SG5KTL-MT","SG6KTL-MT","SG8KTL-M","SG10KTL-M","SG10KTL-MT","SG12KTL-M","SG15KTL-M","SG17KTL-M","SG20KTL-M"] + datarange: + - response: 0xAA + value: "Enable" + - response: 0x55 + value: "Disable" + - name: "export_power_limitation_value" + level: 2 + address: 5011 + datatype: "U16" + models: ["SG5KTL-MT","SG6KTL-MT","SG8KTL-M","SG10KTL-M","SG10KTL-MT","SG12KTL-M","SG15KTL-M","SG17KTL-M","SG20KTL-M"] + - name: "current_transformer_output_current" + level: 2 + address: 5012 + datatype: "U16" + models: ["SG5KTL-MT","SG6KTL-MT","SG8KTL-M","SG10KTL-M","SG10KTL-MT","SG12KTL-M","SG15KTL-M","SG17KTL-M","SG20KTL-M"] + - name: "current_transformer_range" + level: 2 + address: 5013 + datatype: "U16" + models: ["SG5KTL-MT","SG6KTL-MT","SG8KTL-M","SG10KTL-M","SG10KTL-MT","SG12KTL-M","SG15KTL-M","SG17KTL-M","SG20KTL-M"] + - name: "current_transformer" + level: 2 + address: 5014 + datatype: "U16" + models: ["SG5KTL-MT","SG6KTL-MT","SG8KTL-M","SG10KTL-M","SG10KTL-MT","SG12KTL-M","SG15KTL-M","SG17KTL-M","SG20KTL-M"] + datarange: + - response: 0 + value: "Internal" + - response: 1 + value: "External" + - name: "export_power_limitation_percentage" + level: 2 + address: 5015 + datatype: "U16" + models: ["SG5KTL-MT","SG6KTL-MT","SG8KTL-M","SG10KTL-M","SG10KTL-MT","SG12KTL-M","SG15KTL-M","SG17KTL-M","SG20KTL-M"] + - name: "installed_pv_power" + level: 2 + address: 5016 + datatype: "U16" + models: ["SG5KTL-MT","SG6KTL-MT","SG8KTL-M","SG10KTL-M","SG10KTL-MT","SG12KTL-M","SG15KTL-M","SG17KTL-M","SG20KTL-M"] + - name: "power_factor_setting" + level: 2 + address: 5019 + datatype: "U16" + multiple: 0.001 + - name: "scheduling_achieve_active_overload" + level: 2 + address: 5020 + datatype: "U16" + models: ["SG33CX","SG40CX","SG50CX","SG75CX","SG110CX","SG136TX","SG250HX","SG30CX","SG36CX-US","SG60CX-US","SG250HX-US","SG250HX-IN","SG225HX","SG250HX","SG25CX-SA","SG100CX","SG3.0RT","SG4.0RT","SG5.0RT","SG6.0RT","SG7.0RT","SG8.0RT","SG10RT","SG12RT","SG15RT","SG17RT","SG20RT"] + datarange: + - response: 0xAA + value: "Enable" + - response: 0x55 + value: "Disable" +# - name: "Reserved" +# address: 5021-5033 +# datatype: "U16" + - name: "night_svg_switch" + level: 2 + address: 5035 + datatype: "U16" + models: ["SG3.0RT","SG4.0RT","SG5.0RT","SG6.0RT","SG7.0RT","SG8.0RT","SG10RT","SG12RT","SG15RT","SG17RT","SG20RT","SG80KTL-M","SG125HV-20","SG33CX","SG40CX","SG50CX","SG110CX","SG136TX","SG250HX","SG30CX","SG36CX-US","SG60CX-US","SG250HX-US","SG250HX-IN","SG225HX","SG250HX","SG25CX-SA","SG100CX","SG75CX"] + datarange: + - response: 0xAA + value: "Enable" + - response: 0x55 + value: "Disable" + - name: "reactive_power_adjustment_mode" + level: 2 + address: 5036 + datatype: "U16" + datarange: + - response: 0x55 + value: "Off" + - response: 0xA1 + value: "Power factor setting" + - response: 0xA2 + value: "Reactive power percentage setting" + - response: 0xA3 + value: "Enable Q(P)" + - response: 0xA4 + value: "Enable Q(U)" + - name: "reactive_power_percentage_setting" + level: 2 + address: 5037 + datatype: "S16" + multiple: 0.1 +# - name: "Reserved" +# address: 5038 + - name: "power_limitation_adjustment" + level: 2 + address: 5039 + datatype: "U16" + multiple: 0.1 + - name: "reactive_power_adjustment" + level: 2 + address: 5040 + datatype: "S16" + multiple: 0.1 + - name: "pid_recovery" + level: 2 + address: 5041 + datatype: "U16" + models: ["SG5KTL-MT","SG6KTL-MT","SG8KTL-M","SG10KTL-M","SG10KTL-MT","SG12KTL-M","SG15KTL-M","SG17KTL-M","SG20KTL-M","SG3.0RT","SG4.0RT","SG5.0RT","SG6.0RT","SG7.0RT","SG8.0RT","SG10RT","SG12RT","SG15RT","SG17RT","SG20RT","SG80KTL-M","SG125HV","SG125HV-20","SG80KTL","SG33CX","SG40CX","SG50CX","SG100CX、SG75CX","SG110CX","SG136TX","SG250HX","SG30CX","SG36CX-US","SG60CX-US","SG250HX-US","SG250HX-IN","SG25CX-SA","SG225HX"] + datarange: + - response: 0xAA + value: "Enable" + - response: 0x55 + value: "Disable" + - name: "anti_pid" + level: 2 + address: 5042 + datatype: "U16" + models: ["SG125HV","SG125HV-20","SG250HX","SG250HX-US","SG250HX-IN","SG136TX","SG100CX-JP","SG225HX"] + datarange: + - response: 0xAA + value: "Enable" + - response: 0x55 + value: "Disable" + - name: "fullday_pid_suppression" + level: 2 + address: 5043 + datatype: "U16" + models: ["SG250HX","SG250HX-US","SG250HX-IN","SG225HX"] + datarange: + - response: 0xAA + value: "Enable" + - response: 0x55 + value: "Disable" +# - name: "Reserved" +# address: 5043-5047 +# - name: "Q(P) Curve" +# address: 5048-5154 +# - name: "Reserved" +# address: 5155-5199 +scan: # these have to be 1 less than the first register + - read: + - start: "4999" + range: "100" + level: 1 + - start: "5099" + range: "100" + level: 1 + - start: "5199" + range: "100" + level: 3 + - start: "7012" + range: "25" + level: 3 + - hold: +# - start: "4999" +# range: "7" + - start: "4999" + range: "10" + level: 0 + - start: "5009" + range: "10" + level: 2 + - start: "5034" + range: "10" + level: 2 + +# full Models Supported: +#["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"] + + + + + + + + + + + + + + + + + + + + + diff --git a/SunGather/sungather.py b/SunGather/sungather.py new file mode 100644 index 0000000..9c52578 --- /dev/null +++ b/SunGather/sungather.py @@ -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)) \ No newline at end of file diff --git a/SunGather/version.py b/SunGather/version.py new file mode 100644 index 0000000..541f859 --- /dev/null +++ b/SunGather/version.py @@ -0,0 +1 @@ +__version__ = '0.1.0' \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..f8ba1d9 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,5 @@ +paho-mqtt>=1.5.1 +pymodbus>=2.4.0 +SungrowModbusTcpClient>=0.1.5 +SungrowModbusWebClient>=0.2.4 + diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..d247145 --- /dev/null +++ b/setup.py @@ -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', +)