Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add goodwe backend #75

Open
wants to merge 12 commits into
base: main
Choose a base branch
from
Open
13 changes: 4 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ Currently Sunalyzer provides an **English** and a **German** user interface. The

Sunalyzer provides integrations for the following device types (inverters/smart meters):
* Fronius (Symo/Gen24)
* Goodwe D-NS series inverters (and possibly DT)
* HomeWizard P1 Wifi devices for reading Smart Meter data
* Dummy device (for testing purposes)

Contributions for the support of additional devices are welcome. Please feel free to reach out to me or submit a pull request directly.
Expand Down Expand Up @@ -86,22 +88,15 @@ Sunalyzer is configured via a YAML file called *config.yml*. This file has to be
| ----------------------------- | --------------------------------------------------------------------------------------------------- |
| logging | Can be 'normal' (only basic logging) or 'verbose' (verbose logging for debug purposes). |
| time_zone | The time zone that will be used to generate time stamps for logged data. E.g. "Europe/Berlin". |
| device:type | Name of the device plugin to use. Currently "Fronius" and "Dummy" are supported. |
| device:type | Name of the device plugin to use. "Fronius", "Goodwe", HomeWizardP1 and "Dummy" are supported. |
| device:start_date | The date on which the inverter first started production (YYYY-MM-DD). |
| device:host_name | IP address or host name of your device |
| prices:price_per_grid_kwh | Price for 1 kWh consumed from the grid (e.g. in €). |
| prices:revenue_per_fed_in_kwh | Revenue for 1 fed in kWh (e.g. in €). |
| server:ip | IP address of the web server. Should be set to 0.0.0.0. |
| server:port | Port of the web server. Should be set to 5000. |
| grabber:interval_s | Interval in seconds that the grabber will use to query the inverter/smart meter. Default is 3s. |

Additional settings are required depending on the selected device plugin:

#### Fronius

| Setting | Description |
| ----------------------------- | ------------------------------------------------------------- |
| fronius::host_name | IP address or host name of your fronius inverter. |

## Deveopment Environment

Sunalyzer is currently being developed using the following tools and libraries:
Expand Down
4 changes: 3 additions & 1 deletion backend/devices/Dummy.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Dummy device for testing purposes
class Dummy:
def __init__(self, config):
def __init__(self, config, _id):
# Demo code for config access
# print(f"""Dummy device: config test -
# foo={config.config_data['dummy']['foo']}
Expand All @@ -10,6 +10,7 @@ def __init__(self, config):
self.total_energy_produced_kwh = 440.0
self.total_energy_consumed_kwh = 390.0
self.total_energy_fed_in_kwh = 240.0
self.total_energy_consumed_from_grid_kwh = 1234.0

self.current_power_produced_kw = 3.0
self.current_power_consumed_from_grid_kw = 0.0
Expand All @@ -23,6 +24,7 @@ def update(self):
self.total_energy_produced_kwh = self.total_energy_produced_kwh + 1
self.total_energy_consumed_kwh = self.total_energy_consumed_kwh + 1
self.total_energy_fed_in_kwh = self.total_energy_fed_in_kwh + 1
# self.total_energy_consumed_from_grid_kwh = total_consumed_from_grid_kwh

# self.current_power_produced_kw = self.current_power_produced_kw
# self.current_power_consumed_from_grid_kw = self.current_power_consumed_from_grid_kw
Expand Down
22 changes: 22 additions & 0 deletions backend/devices/Empty.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# Empty device to allow adding values from multiple devices
class Empty:
def __init__(self, config, _id):
# Demo code for config access
# print(f"""Dummy device: config test -
# foo={config.config_data['dummy']['foo']}
# bar={config.config_data['dummy']['bar']}""")

# Initialize with some random values
self.total_energy_produced_kwh = 0.0
self.total_energy_consumed_kwh = 0.0
self.total_energy_fed_in_kwh = 0.0
self.total_energy_consumed_from_grid_kwh = 0.0

self.current_power_produced_kw = 0.0
self.current_power_consumed_from_grid_kw = 0.0
self.current_power_consumed_from_pv_kw = 0.0
self.current_power_consumed_total_kw = 0.0
self.current_power_fed_in_kw = 0.0

def update(self):
'''Nothing to do'''
33 changes: 29 additions & 4 deletions backend/devices/Fronius.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,23 +4,47 @@

# Fronius Symo/Gn24 devices
class Fronius:
def __init__(self, config):
def __init__(self, config, _id):
# Demo code for config access
hostname_configured = True
try:
self.host_name = config.config_data[_id]['host_name'] # use _id to get the "device2" section
except KeyError:
logging.info(f"Grabber: Fronius device does not have a hostname in the '{_id}:' section of config.yml")
hostname_configured = False
except Exception as e:
logging.exception(e)

if hostname_configured is False:
try:
self.host_name = config.config_data['fronius']['host_name']
except KeyError:
logging.info(f"Grabber: config.yml does not contain a hostname for "
f"{config.config_data[_id]['type']} declared in section '{_id}:'")
raise
except Exception as e:
logging.exception(e)
else:
logging.info(f"Grabber: took host_name from the 'fronius:' section in config.yml."
f" Please move option to the '{_id}:' section instead")

logging.info(f"Fronius device: "
f"configured host name is "
f"{config.config_data['fronius']['host_name']}")

self.host_name = config.config_data['fronius']['host_name']
f"{config.config_data[_id]['host_name']}")

self.url_inverter = (
f"http://{self.host_name}/solar_api/v1/GetPowerFlowRealtimeData.fcgi")
self.url_meter = (
f"http://{self.host_name}/solar_api/v1/GetMeterRealtimeData.cgi?Scope=System")

self.specific_option = config.config_data['fronius']['specific_option'] # not currently used

# Initialize with default values
self.total_energy_produced_kwh = 0.0
self.total_energy_consumed_kwh = 0.0
self.total_energy_fed_in_kwh = 0.0
self.total_energy_consumed_from_grid_kwh = 0.0

self.current_power_consumed_from_grid_kw = 0.0
self.current_power_consumed_from_pv_kw = 0.0
self.current_power_consumed_total_kw = 0.0
Expand Down Expand Up @@ -62,6 +86,7 @@ def copy_data(self, inverter_data, meter_data):
self.total_energy_produced_kwh = total_produced_kwh
self.total_energy_consumed_kwh = total_consumption_kwh
self.total_energy_fed_in_kwh = total_fed_in_kwh
self.total_energy_consumed_from_grid_kwh = total_consumed_from_grid_kwh

# Now extract the momentary values
str_cur_production_w = inverter_data["Body"]["Data"]["Site"]["P_PV"]
Expand Down
225 changes: 225 additions & 0 deletions backend/devices/GoodweDNS.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,225 @@
import requests
import logging
import socket
from datetime import datetime
from collections import namedtuple
import struct


# this implementation borrows very heavily from https://github.com/borft/py-goodwe
# and https://github.com/marcelblijleven/goodwe

# Goodwe devices
class GoodweDNS:
def __init__(self, config, _id):
# Demo code for config access
hostname_configured = False
try:
self.host_name = config.config_data[_id]['host_name'] # use _id to get the "device2" section
except KeyError:
logging.info(f"Grabber: Goodwe device does not have a hostname in the '{_id}:' section of config.yaml")
except Exception as e:
logging.exception(e)
else:
hostname_configured = True

if hostname_configured is False:
try:
self.host_name = config.config_data['goodwe_dns']['host_name']
except KeyError:
logging.info(f"Grabber: config.yaml does not contain a hostname for "
f"{config.config_data[_id]['type']} declared in section '{_id}:'")
raise
except Exception as e:
logging.exception(e)
else:
logging.info(f"Grabber: took host_name from the 'goodwe_dns:' section in config.yaml."
f" Please move option to the '{_id}:' section instead")

self.goodwe_port = 8899
self.last_response = datetime.now()

logging.info(f"GoodweDNS device: "
f"configured host name is "
f"{self.host_name}")

self.specific_option = config.config_data['goodwe_dns']['specific_option'] # not currently used

# Initialize with default values
self.total_energy_produced_kwh = 0.0
self.total_energy_consumed_kwh = 0.0
self.total_energy_fed_in_kwh = 0.0
self.total_energy_consumed_from_grid_kwh = 0.0

self.current_power_produced_kw = 0.0
self.current_power_consumed_from_grid_kw = 0.0
self.current_power_consumed_from_pv_kw = 0.0
self.current_power_consumed_total_kw = 0.0
self.current_power_fed_in_kw = 0.0

self.run_data = namedtuple('GoodweDNS', '\
year \
month \
day \
hour \
minute \
second \
vpv1 \
ipv1 \
vpv2 \
ipv2 \
vpv3 \
ipv3 \
vpv4 \
ipv4 \
vpv5 \
ipv5 \
ipv6 \
vpv6 \
vline1 \
vline2 \
vline3 \
vgrid1 \
vgrid2 \
vgrid3 \
igrid1 \
igrid2 \
igrid3 \
fgrid1 \
fgrid2 \
fgrid3 \
xx54 \
ppv \
work_mode \
error_codes \
warning_code \
xx66 \
xx68 \
xx70 \
xx72 \
xx74 \
xx76 \
xx78 \
xx80 \
temperature \
xx84 \
xx86 \
e_day \
e_total \
h_total \
safety_country \
xx100 \
xx102 \
xx104 \
xx106 \
xx108 \
xx110 \
xx112 \
xx114 \
xx116 \
xx118 \
xx120 \
xx122 \
funbit \
vbus \
vnbus \
xx130 \
xx132 \
xx134 \
xx136 \
xx138')

self.format_list = '!BBBBBBHHHHHHHHHHHHHHHHHHhhhHHHHHHLHHHHHHHHHHHHHLLHHHHHHHHHHHHHHHHHHHHH'

# Test connection by doing an initial update
try:
self.update()
except Exception:
logging.error(
"Goodwe device: Error: connecting to the device failed")
# raise # disable the raise since goodwe inverters go offline if the panels are not producing power

def update(self):
'''Updates all device stats.'''
try:
# Query inverter received_data
# inverter = await goodwe.connect(self.host_name)
runtime_data = self.getData()

runtime_values = self.run_data._make(struct.unpack_from(self.format_list, runtime_data, 3))

# Store results
self.current_power_produced_kw = runtime_values.ppv / 1000
self.total_energy_produced_kwh = runtime_values.e_total / 10
logging.debug(f'Data received for power [{runtime_values.ppv}] and production [{runtime_values.e_total}]')
logging.debug(f'Set power to {self.current_power_produced_kw} kW '
f'and production to {self.total_energy_produced_kwh} kWh')

except requests.exceptions.Timeout:
logging.error(f"Goodwe device: Timeout requesting "
f"'{self.url_inverter}' or '{self.url_meter}'")
raise
except requests.exceptions.RequestException as e:
logging.error(f"Goodwe device: requests exception {e} for URL "
f"'{self.url_inverter}' or '{self.url_meter}'")
raise

@staticmethod
def getCRC(run_data_bytes: list[bytes]) -> list[bytes]:
crc = 0xFFFF
odd = False

for i in range(0, len(run_data_bytes)):
crc ^= run_data_bytes[i]

for j in range(0, 8):
odd = (crc & 0x0001) != 0
crc >>= 1
if odd:
crc ^= 0xA001
return crc.to_bytes(2, byteorder='little', signed=False)

def getData(self, retries=3):
while retries > 0:
retries -= 1
try:
return self._getData(self.host_name, self.goodwe_port)
except Exception as e:
logging.debug(f'Retrying {retries} {e}')
if datetime.now().timestamp() - self.last_response.timestamp() > 60:
self.current_power_produced_kw = 0
pass
raise Exception('Could not get proper received_data after retrying')

def _getData(self, ip, port):

# get received_data from inverter
udp_connection = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
udp_connection.settimeout(1)
runtime_query = bytes(b'\x7f\x03\x75\x94\x00\x49\xd5\xc2')
udp_connection.sendto(runtime_query, (ip, port)) # TODO; handle exception

response = udp_connection.recvfrom(1024)
logging.debug(f'Got response from server: {response}')
self.last_response = datetime.now()

# the first part is a list of bytes
received_data = bytes(response[0])

# check length of packet
if len(received_data) != 153:
raise Exception(f'Data received was not usable (unexpected length: {len(received_data)}')

# check header
header = received_data[0:2]
if header != b'\xaa\x55':
raise Exception(f'Data received has invalid header: {header}')

# check CRC
receivedCRC = received_data[-2:]
run_data_bytes = received_data[2:151]
calculatedCRC = self.getCRC(run_data_bytes)
if receivedCRC != calculatedCRC:
raise Exception(f'CRC error: received {receivedCRC}, calulated {calculatedCRC}')

return run_data_bytes
Loading