Initial Commit

This commit is contained in:
bohdan-s
2021-12-16 16:14:45 +11:00
parent 59fe00b2ac
commit 0e95c25f46
13 changed files with 1722 additions and 0 deletions
+2
View File
@@ -1,3 +1,5 @@
config.yaml
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
+124
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
from .version import __version__
+102
View File
@@ -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:
View File
+19
View File
@@ -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
+64
View File
@@ -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
+104
View File
@@ -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
+261
View File
@@ -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))
+1
View File
@@ -0,0 +1 @@
__version__ = '0.1.0'
+5
View File
@@ -0,0 +1,5 @@
paho-mqtt>=1.5.1
pymodbus>=2.4.0
SungrowModbusTcpClient>=0.1.5
SungrowModbusWebClient>=0.2.4
+28
View File
@@ -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',
)