From 53e1f194af1314a4db926e4f8ed8ded9bfd98c5b Mon Sep 17 00:00:00 2001 From: Robbert Lagerweij Date: Thu, 13 Apr 2023 17:35:20 +0200 Subject: [PATCH 01/12] add -debug command line option to grabber.py --- backend/grabber.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/backend/grabber.py b/backend/grabber.py index ce9e349..df87b82 100644 --- a/backend/grabber.py +++ b/backend/grabber.py @@ -1,4 +1,5 @@ import os +import sys import time import logging import importlib @@ -350,6 +351,11 @@ def main(): level=logging.INFO, datefmt='%Y-%m-%d %H:%M:%S') + if "-debug" in sys.argv: + # also print to stderr + logging.getLogger().addHandler(logging.StreamHandler()) + logging.getLogger().setLevel(logging.DEBUG) + # Print version logging.info(f"Starting Sunalyzer grabber version {version.get_version()}") @@ -360,8 +366,9 @@ def main(): except Exception: exit() - # Set log level - logging.getLogger().setLevel(config.log_level) + # Set log level based on config file if not yet set with "-debug" command line option + if logging.getLogger().getEffectiveLevel() != logging.DEBUG: + logging.getLogger().setLevel(config.log_level) # Set time zone set_time_zone(config.config_data.get("time_zone")) From fe285bd83caa0a70b7a9945f79fdf8aeaa6102b0 Mon Sep 17 00:00:00 2001 From: Robbert Lagerweij Date: Tue, 18 Apr 2023 17:08:09 +0200 Subject: [PATCH 02/12] set default log level to normal in template config --- templates/config.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/templates/config.yml b/templates/config.yml index 20d4903..fdf42bd 100644 --- a/templates/config.yml +++ b/templates/config.yml @@ -1,7 +1,7 @@ ## User Settings # Logging configuration: normal or verbose. -logging: verbose +logging: normal # Sets the local time zone. time_zone: "Europe/Berlin" From 025ded8bfd2091784327b315fc6b9fdf6bdc0557 Mon Sep 17 00:00:00 2001 From: Robbert Lagerweij Date: Thu, 13 Apr 2023 17:38:22 +0200 Subject: [PATCH 03/12] expose energy consumed from grid directly instead of through consumption --- backend/devices/Dummy.py | 2 ++ backend/devices/Fronius.py | 3 +++ 2 files changed, 5 insertions(+) diff --git a/backend/devices/Dummy.py b/backend/devices/Dummy.py index 88f0824..dd99b1c 100644 --- a/backend/devices/Dummy.py +++ b/backend/devices/Dummy.py @@ -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 @@ -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 diff --git a/backend/devices/Fronius.py b/backend/devices/Fronius.py index 772e4e9..546c50b 100644 --- a/backend/devices/Fronius.py +++ b/backend/devices/Fronius.py @@ -21,6 +21,8 @@ def __init__(self, config): 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 @@ -62,6 +64,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"] From 2ff991a5eedc5987df2f452da50e73cf036c217b Mon Sep 17 00:00:00 2001 From: Robbert Lagerweij Date: Thu, 13 Apr 2023 17:40:51 +0200 Subject: [PATCH 04/12] add ability to get data from multiple devices to the grabber --- backend/devices/Empty.py | 22 ++++++++++++ backend/grabber.py | 72 ++++++++++++++++++++++++++++++++++------ templates/config.yml | 17 ++++++++++ 3 files changed, 100 insertions(+), 11 deletions(-) create mode 100644 backend/devices/Empty.py diff --git a/backend/devices/Empty.py b/backend/devices/Empty.py new file mode 100644 index 0000000..a12a25f --- /dev/null +++ b/backend/devices/Empty.py @@ -0,0 +1,22 @@ +# Empty device to allow adding values from multiple devices +class Empty: + def __init__(self, config): + # 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''' diff --git a/backend/grabber.py b/backend/grabber.py index df87b82..41268c4 100644 --- a/backend/grabber.py +++ b/backend/grabber.py @@ -234,7 +234,44 @@ def set_time_zone(tz): # Updates data in the data base -def update_data(device): +def update_data(devices): + totals = load_device_plugin("Empty") # start with an empty object + + for device in devices: # add available values from each device (will be zero if not available) + try: + device.update() + except Exception: + logging.debug(f"Device upate failed: {device.__class__ }") + + totals.total_energy_produced_kwh += device.total_energy_produced_kwh + totals.total_energy_consumed_from_grid_kwh += device.total_energy_consumed_from_grid_kwh + totals.total_energy_fed_in_kwh += device.total_energy_fed_in_kwh + totals.current_power_produced_kw += device.current_power_produced_kw + totals.current_power_consumed_from_grid_kw += device.current_power_consumed_from_grid_kw + totals.current_power_fed_in_kw += device.current_power_fed_in_kw + + # totals.total_energy_consumed_kwh += device.total_energy_consumed_kwh + # totals.current_power_consumed_from_pv_kw += device.current_power_consumed_from_pv_kw + # totals.current_power_consumed_total_kw += device.current_power_consumed_total_kw + + totals.total_energy_consumed_kwh = \ + totals.total_energy_produced_kwh + \ + totals.total_energy_consumed_from_grid_kwh - \ + totals.total_energy_fed_in_kwh + + totals.current_power_consumed_from_pv_kw = \ + totals.current_power_produced_kw - \ + totals.current_power_fed_in_kw + + totals.current_power_consumed_total_kw = \ + totals.current_power_consumed_from_pv_kw + \ + totals.current_power_consumed_from_grid_kw + + store_data(totals) + + +def store_data(device): + '''Updates data in the data base.''' global real_time_seconds_counter @@ -373,13 +410,26 @@ def main(): # Set time zone set_time_zone(config.config_data.get("time_zone")) - # Dynamically load the device - try: - device_name = config.config_data['device']['type'] - logging.info(f"Grabber: Loading device adapter '{device_name}'") - device = load_device_plugin(device_name) - except Exception: - logging.exception("creating the device adapter failed") + # Dynamically load the devices + devices = [] + suffixes = ("", "2", "3", "4", "5") + for s in suffixes: + try: + section = "device" + s + device_name = config.config_data[section]['type'] + if device_name in ("None", "Empty"): + logging.info(f"Grabber: skipping device adapter '{section}:{device_name}'") + else: + logging.info(f"Grabber: Loading device adapter '{section}:{device_name}'") + device = load_device_plugin(device_name) + # device.hostname = config.config_data[section]['hostname'] + devices.append(device) + except KeyError: + logging.exception(f"Grabber: section {section} does not exist in config.yml") + except Exception: + logging.exception(f"creating the device adapter '{section}:{device_name}' failed") + if devices.count == 0: + logging.error("Grabber: no device adapters loaded. Exiting....") exit() # Prepare the data base @@ -396,9 +446,9 @@ def main(): logging.debug(f"Grabber: {time_string}: Updating device data") try: - update_data(device) - except Exception: - logging.exception("Updating data from device failed") + update_data(devices) + except Exception as e: + logging.exception(f"Updating data from device failed {e}") time.sleep(config.config_data['grabber']['interval_s']) diff --git a/templates/config.yml b/templates/config.yml index fdf42bd..478f066 100644 --- a/templates/config.yml +++ b/templates/config.yml @@ -14,7 +14,24 @@ device: type: Dummy # Name of the device to load (must match an existing device .py file/class) start_date: 2020-08-01 # The start of operation (YYYY-MM-DD) +device2: + type: None # Name of the device to load (must match an existing device .py file/class) + start_date: 2020-01-01 # The start of operation (YYYY-MM-DD) + +device3: + type: None # Name of the device to load (must match an existing device .py file/class) + start_date: 2020-01-01 # The start of operation (YYYY-MM-DD) + +device4: + type: None # Name of the device to load (must match an existing device .py file/class) + start_date: 2020-01-01 # The start of operation (YYYY-MM-DD) + +device5: + type: None # Name of the device to load (must match an existing device .py file/class) + start_date: 2020-01-01 # The start of operation (YYYY-MM-DD) + # Device specific settings. These depend on the selected device adapter + # Enable if you want to use Fronius hardware #fronius: # host_name: 192.168.178.200 # Host name/IP address of the Fronius end point From e07096d1c1f88244164b413086ae0cba1f48d859 Mon Sep 17 00:00:00 2001 From: Robbert Lagerweij Date: Tue, 18 Apr 2023 13:33:03 +0200 Subject: [PATCH 05/12] add -no_store command line option to grabber.py allow developers to prevent database writes during testing --- backend/grabber.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/backend/grabber.py b/backend/grabber.py index 41268c4..0cc352e 100644 --- a/backend/grabber.py +++ b/backend/grabber.py @@ -272,6 +272,10 @@ def update_data(devices): def store_data(device): + if "-no_store" in sys.argv: + logging.debug("Grabber: skipping storage to database") + return + '''Updates data in the data base.''' global real_time_seconds_counter From 27a84e09b7f73a42241ce3dc028d8b3b4b4f6be8 Mon Sep 17 00:00:00 2001 From: Robbert Lagerweij Date: Thu, 13 Apr 2023 17:43:52 +0200 Subject: [PATCH 06/12] add backend for Goodwe D-NS series solar inverter --- backend/devices/GoodweDNS.py | 201 +++++++++++++++++++++++++++++++++++ templates/config.yml | 5 + 2 files changed, 206 insertions(+) create mode 100644 backend/devices/GoodweDNS.py diff --git a/backend/devices/GoodweDNS.py b/backend/devices/GoodweDNS.py new file mode 100644 index 0000000..c5553b4 --- /dev/null +++ b/backend/devices/GoodweDNS.py @@ -0,0 +1,201 @@ +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): + # Demo code for config access + logging.info(f"GoodweDNS device: " + f"configured host name is " + f"{config.config_data['goodwe_dns']['host_name']}") + + self.host_name = config.config_data['goodwe_dns']['host_name'] + self.goodwe_port = 8899 + self.last_response = datetime.now() + + # 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 diff --git a/templates/config.yml b/templates/config.yml index 478f066..bfa8a1e 100644 --- a/templates/config.yml +++ b/templates/config.yml @@ -10,6 +10,7 @@ time_zone: "Europe/Berlin" # Currently the following devices are available # - Dummy: a simple dummy devices that can be used for testing # - Fronius: supports Fronius Symo/GEN24 inverters + smar meter +# - GoodweDNS: supports Goodwe DNS series inverters device: type: Dummy # Name of the device to load (must match an existing device .py file/class) start_date: 2020-08-01 # The start of operation (YYYY-MM-DD) @@ -36,6 +37,10 @@ device5: #fronius: # host_name: 192.168.178.200 # Host name/IP address of the Fronius end point +# Enable if you want to use Goodwe hardware +#goodwe_dns: +# host_name: 192.168.178.200 # Host name/IP address of the GoodweDNS end point + # Electricity prices. Used to calculate earnings in the UI. prices: price_per_grid_kwh: 0.325 # Price for 1 kWh from the grid in Euro From c3d0f9a4b6d998e0f758967a9efccf558ecfeb69 Mon Sep 17 00:00:00 2001 From: Robbert Lagerweij Date: Thu, 13 Apr 2023 17:44:43 +0200 Subject: [PATCH 07/12] add backend for HomeWizard Wifi P1 energy meter --- backend/devices/HomeWizardP1.py | 89 +++++++++++++++++++++++++++++++++ templates/config.yml | 4 ++ 2 files changed, 93 insertions(+) create mode 100644 backend/devices/HomeWizardP1.py diff --git a/backend/devices/HomeWizardP1.py b/backend/devices/HomeWizardP1.py new file mode 100644 index 0000000..5aeb5f7 --- /dev/null +++ b/backend/devices/HomeWizardP1.py @@ -0,0 +1,89 @@ +import requests +import logging + + +# HomeWizard P1 devices +class HomeWizardP1: + def __init__(self, config): + # Demo code for config access + logging.info(f"HomeWizard P1 device: " + f"configured host name is " + f"{config.config_data['homewizardp1']['host_name']}") + + self.host_name = config.config_data['homewizardp1']['host_name'] + + self.url_meter = ( + f"http://{self.host_name}/api/v1/data") + + # 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 + + # Test connection by doing an initial update + try: + self.update() + except Exception: + logging.error( + "HomeWizard P1: Error: connecting to the device failed") + raise + + def copy_data(self, meter_data): + '''Copies the results from the API request.''' + # Meter data + total_consumed_from_grid_kwh = meter_data["total_power_import_kwh"] + total_fed_in_kwh = meter_data["total_power_export_kwh"] + # Compute other values + # total_self_consumption_kwh = total_produced_kwh - total_fed_in_kwh + # total_consumption_kwh = total_consumed_from_grid_kwh + total_self_consumption_kwh + + # Logging + if logging.getLogger().level == logging.DEBUG: + logging.debug(f"HomeWizard P1: Absolute values:\n" + f" - Total grid consumption: {str(total_consumed_from_grid_kwh)} kWh\n" + f" - Total fed in: {str(total_fed_in_kwh)} kWh") + + # Total/absolute values + self.total_energy_fed_in_kwh = total_fed_in_kwh + self.total_energy_consumed_from_grid_kwh = total_consumed_from_grid_kwh + # self.total_energy_consumed_in_kwh = total_consumed_from_grid_kwh + + # Now extract the momentary values + str_grid_power_w = meter_data["active_power_w"] + grid_power_kw = float(str_grid_power_w) * 0.001 + cur_feed_in_kw = min(grid_power_kw, 0.0) * -1 + cur_consumption_from_grid = max(grid_power_kw, 0.0) + + # Logging + if logging.getLogger().level == logging.DEBUG: + logging.debug(f"HomeWizard P1 device: Momentary values:\n" + f" - Current feed-in: {str(cur_feed_in_kw)} kW\n" + f" - Current consumption from grid: {str(cur_consumption_from_grid)} kW") + + # Store results + self.current_power_fed_in_kw = cur_feed_in_kw + self.current_power_consumed_from_grid_kw = cur_consumption_from_grid + + def update(self): + '''Updates all device stats.''' + try: + # Query smart meter data + r_meter = requests.get(self.url_meter, timeout=5) + r_meter.raise_for_status() + # Extract and process relevant data + self.copy_data(r_meter.json()) + except requests.exceptions.Timeout: + logging.error(f"HomeWizard P1: Timeout requesting " + f"'{self.url_meter}'") + raise + except requests.exceptions.RequestException as e: + logging.error(f"HomeWizard P1: requests exception {e} for URL " + f"'{self.url_meter}'") + raise diff --git a/templates/config.yml b/templates/config.yml index bfa8a1e..e27976c 100644 --- a/templates/config.yml +++ b/templates/config.yml @@ -11,6 +11,7 @@ time_zone: "Europe/Berlin" # - Dummy: a simple dummy devices that can be used for testing # - Fronius: supports Fronius Symo/GEN24 inverters + smar meter # - GoodweDNS: supports Goodwe DNS series inverters +# - HomeWizardP1: supports HomeWizard Wi-Fi P1 meter device: type: Dummy # Name of the device to load (must match an existing device .py file/class) start_date: 2020-08-01 # The start of operation (YYYY-MM-DD) @@ -41,6 +42,9 @@ device5: #goodwe_dns: # host_name: 192.168.178.200 # Host name/IP address of the GoodweDNS end point +#homewizardp1: +# host_name: 192.168.178.200 # Host name/IP address of the HomeWizard Wi-Fi P1 meter end point + # Electricity prices. Used to calculate earnings in the UI. prices: price_per_grid_kwh: 0.325 # Price for 1 kWh from the grid in Euro From db2e595d3ba1bfc6754c7e02637024c48c8fbf1b Mon Sep 17 00:00:00 2001 From: Robbert Lagerweij Date: Tue, 18 Apr 2023 15:34:07 +0200 Subject: [PATCH 08/12] rename device_name variable to clarify it indicates type --- backend/grabber.py | 23 +++++++++++------------ 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/backend/grabber.py b/backend/grabber.py index 0cc352e..4eeca02 100644 --- a/backend/grabber.py +++ b/backend/grabber.py @@ -213,10 +213,10 @@ def create_new_db(): # Loads the device class with the given name -def load_device_plugin(device_name): - '''Loads the device class with the given name.''' - module = importlib.import_module("devices." + device_name) - class_ = getattr(module, device_name) +def load_device_plugin(device_type): + '''Loads the device class with the given type.''' + module = importlib.import_module("devices." + device_type) + class_ = getattr(module, device_type) device = class_(config) return device @@ -420,18 +420,17 @@ def main(): for s in suffixes: try: section = "device" + s - device_name = config.config_data[section]['type'] - if device_name in ("None", "Empty"): - logging.info(f"Grabber: skipping device adapter '{section}:{device_name}'") + device_type = config.config_data[section]['type'] + if device_type in ("None", "Empty"): + logging.info(f"Grabber: skipping device adapter '{section}:{device_type}'") else: - logging.info(f"Grabber: Loading device adapter '{section}:{device_name}'") - device = load_device_plugin(device_name) - # device.hostname = config.config_data[section]['hostname'] + logging.info(f"Grabber: Loading device adapter '{section}:{device_type}'") + device = load_device_plugin(device_type, section) devices.append(device) except KeyError: - logging.exception(f"Grabber: section {section} does not exist in config.yml") + logging.exception(f"Grabber: section '{section}' does not exist in config.yml") except Exception: - logging.exception(f"creating the device adapter '{section}:{device_name}' failed") + logging.exception(f"creating the device adapter '{section}:{device_type}' failed") if devices.count == 0: logging.error("Grabber: no device adapters loaded. Exiting....") exit() From 347745a262e1801c1d0426fa72c388b4ac7aff56 Mon Sep 17 00:00:00 2001 From: Robbert Lagerweij Date: Tue, 18 Apr 2023 15:38:34 +0200 Subject: [PATCH 09/12] allow for two or more devices of the same type by moving the hostname setting in config.yml to the device section instead of the type section --- backend/devices/Dummy.py | 2 +- backend/devices/Empty.py | 2 +- backend/devices/Fronius.py | 10 ++++++---- backend/devices/GoodweDNS.py | 12 +++++++----- backend/devices/HomeWizardP1.py | 10 ++++++---- backend/grabber.py | 6 +++--- templates/config.yml | 20 +++++++++++--------- 7 files changed, 35 insertions(+), 27 deletions(-) diff --git a/backend/devices/Dummy.py b/backend/devices/Dummy.py index dd99b1c..b0293e1 100644 --- a/backend/devices/Dummy.py +++ b/backend/devices/Dummy.py @@ -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']} diff --git a/backend/devices/Empty.py b/backend/devices/Empty.py index a12a25f..f823a52 100644 --- a/backend/devices/Empty.py +++ b/backend/devices/Empty.py @@ -1,6 +1,6 @@ # Empty device to allow adding values from multiple devices class Empty: - 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']} diff --git a/backend/devices/Fronius.py b/backend/devices/Fronius.py index 546c50b..13d6532 100644 --- a/backend/devices/Fronius.py +++ b/backend/devices/Fronius.py @@ -4,19 +4,21 @@ # Fronius Symo/Gn24 devices class Fronius: - def __init__(self, config): + def __init__(self, config, _id): # Demo code for config access + self.host_name = config.config_data[_id]['host_name'] # use _id to get the "device2" section + 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 diff --git a/backend/devices/GoodweDNS.py b/backend/devices/GoodweDNS.py index c5553b4..f02df4e 100644 --- a/backend/devices/GoodweDNS.py +++ b/backend/devices/GoodweDNS.py @@ -11,15 +11,17 @@ # Goodwe devices class GoodweDNS: - def __init__(self, config): + def __init__(self, config, _id): # Demo code for config access + self.host_name = config.config_data[_id]['host_name'] # use _id to get the "device2" section + self.goodwe_port = 8899 + self.last_response = datetime.now() + logging.info(f"GoodweDNS device: " f"configured host name is " - f"{config.config_data['goodwe_dns']['host_name']}") + f"{self.host_name}") - self.host_name = config.config_data['goodwe_dns']['host_name'] - self.goodwe_port = 8899 - self.last_response = datetime.now() + self.specific_option = config.config_data['goodwe_dns']['specific_option'] # not currently used # Initialize with default values self.total_energy_produced_kwh = 0.0 diff --git a/backend/devices/HomeWizardP1.py b/backend/devices/HomeWizardP1.py index 5aeb5f7..e428773 100644 --- a/backend/devices/HomeWizardP1.py +++ b/backend/devices/HomeWizardP1.py @@ -4,17 +4,19 @@ # HomeWizard P1 devices class HomeWizardP1: - def __init__(self, config): + def __init__(self, config, _id): # Demo code for config access + self.host_name = config.config_data[_id]['host_name'] # use _id to get the "device2" section + logging.info(f"HomeWizard P1 device: " f"configured host name is " - f"{config.config_data['homewizardp1']['host_name']}") - - self.host_name = config.config_data['homewizardp1']['host_name'] + f"{config.config_data[_id]['host_name']}") self.url_meter = ( f"http://{self.host_name}/api/v1/data") + 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 diff --git a/backend/grabber.py b/backend/grabber.py index 4eeca02..3bcb11a 100644 --- a/backend/grabber.py +++ b/backend/grabber.py @@ -213,11 +213,11 @@ def create_new_db(): # Loads the device class with the given name -def load_device_plugin(device_type): +def load_device_plugin(device_type, section): '''Loads the device class with the given type.''' module = importlib.import_module("devices." + device_type) class_ = getattr(module, device_type) - device = class_(config) + device = class_(config, section) return device @@ -235,7 +235,7 @@ def set_time_zone(tz): # Updates data in the data base def update_data(devices): - totals = load_device_plugin("Empty") # start with an empty object + totals = load_device_plugin("Empty", "none") # start with an empty object for device in devices: # add available values from each device (will be zero if not available) try: diff --git a/templates/config.yml b/templates/config.yml index e27976c..0f78923 100644 --- a/templates/config.yml +++ b/templates/config.yml @@ -15,35 +15,37 @@ time_zone: "Europe/Berlin" device: type: Dummy # Name of the device to load (must match an existing device .py file/class) start_date: 2020-08-01 # The start of operation (YYYY-MM-DD) + host_name: 192.168.178.200 # Host name/IP address of the end point device2: type: None # Name of the device to load (must match an existing device .py file/class) start_date: 2020-01-01 # The start of operation (YYYY-MM-DD) + host_name: 192.168.178.200 # Host name/IP address of the end point device3: type: None # Name of the device to load (must match an existing device .py file/class) start_date: 2020-01-01 # The start of operation (YYYY-MM-DD) + host_name: 192.168.178.200 # Host name/IP address of the end point device4: type: None # Name of the device to load (must match an existing device .py file/class) start_date: 2020-01-01 # The start of operation (YYYY-MM-DD) + host_name: 192.168.178.200 # Host name/IP address of the end point device5: type: None # Name of the device to load (must match an existing device .py file/class) start_date: 2020-01-01 # The start of operation (YYYY-MM-DD) + host_name: 192.168.178.200 # Host name/IP address of the end point # Device specific settings. These depend on the selected device adapter +fronius: + specific_option: None # allows for further passing of options, currently not used -# Enable if you want to use Fronius hardware -#fronius: -# host_name: 192.168.178.200 # Host name/IP address of the Fronius end point +goodwe_dns: + specific_option: None # allows for further passing of options, currently not used -# Enable if you want to use Goodwe hardware -#goodwe_dns: -# host_name: 192.168.178.200 # Host name/IP address of the GoodweDNS end point - -#homewizardp1: -# host_name: 192.168.178.200 # Host name/IP address of the HomeWizard Wi-Fi P1 meter end point +homewizardp1: + specific_option: None # allows for further passing of options, currently not used # Electricity prices. Used to calculate earnings in the UI. prices: From 1505bb357b0afff051e9e8197d8a5331d28c2b45 Mon Sep 17 00:00:00 2001 From: Robbert Lagerweij Date: Thu, 20 Apr 2023 20:59:10 +0200 Subject: [PATCH 10/12] add compatibility with hostnames defined in e.g. the " fronius:" section as it was used before the multiple device commit --- backend/devices/Fronius.py | 22 +++++++++++++++++++++- backend/devices/GoodweDNS.py | 24 +++++++++++++++++++++++- backend/devices/HomeWizardP1.py | 23 ++++++++++++++++++++++- 3 files changed, 66 insertions(+), 3 deletions(-) diff --git a/backend/devices/Fronius.py b/backend/devices/Fronius.py index 13d6532..5d55e36 100644 --- a/backend/devices/Fronius.py +++ b/backend/devices/Fronius.py @@ -6,7 +6,27 @@ class Fronius: def __init__(self, config, _id): # Demo code for config access - self.host_name = config.config_data[_id]['host_name'] # use _id to get the "device2" section + 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 " diff --git a/backend/devices/GoodweDNS.py b/backend/devices/GoodweDNS.py index f02df4e..5295b28 100644 --- a/backend/devices/GoodweDNS.py +++ b/backend/devices/GoodweDNS.py @@ -13,7 +13,29 @@ class GoodweDNS: def __init__(self, config, _id): # Demo code for config access - self.host_name = config.config_data[_id]['host_name'] # use _id to get the "device2" section + 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() diff --git a/backend/devices/HomeWizardP1.py b/backend/devices/HomeWizardP1.py index e428773..b4f5be5 100644 --- a/backend/devices/HomeWizardP1.py +++ b/backend/devices/HomeWizardP1.py @@ -6,7 +6,28 @@ class HomeWizardP1: def __init__(self, config, _id): # Demo code for config access - self.host_name = config.config_data[_id]['host_name'] # use _id to get the "device2" section + 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: HomeWizard device does not have a hostname in the '{_id}:' section of config.yml") + except Exception as e: + logging.exception(e) + else: + hostname_configured = True + + if hostname_configured is False: + try: + self.host_name = config.config_data['homewizardp1']['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 'homewizardp1:' section in config.yml." + f" Please move option to the '{_id}:' section instead") logging.info(f"HomeWizard P1 device: " f"configured host name is " From f37ddf34ab9a86985cfe300d186e4a74343f002f Mon Sep 17 00:00:00 2001 From: Robbert Lagerweij Date: Thu, 20 Apr 2023 20:59:59 +0200 Subject: [PATCH 11/12] add small config hint --- templates/config.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/templates/config.yml b/templates/config.yml index 0f78923..7ac5c4a 100644 --- a/templates/config.yml +++ b/templates/config.yml @@ -12,6 +12,7 @@ time_zone: "Europe/Berlin" # - Fronius: supports Fronius Symo/GEN24 inverters + smar meter # - GoodweDNS: supports Goodwe DNS series inverters # - HomeWizardP1: supports HomeWizard Wi-Fi P1 meter +# Please configure a device which measures PV production as 'device:' device: type: Dummy # Name of the device to load (must match an existing device .py file/class) start_date: 2020-08-01 # The start of operation (YYYY-MM-DD) From 0ded6f72fab81c8ee7273d4d7aefafb7084ceb93 Mon Sep 17 00:00:00 2001 From: Robbert Lagerweij Date: Thu, 20 Apr 2023 21:13:24 +0200 Subject: [PATCH 12/12] update README.md --- README.md | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 29c53fa..391ca16 100644 --- a/README.md +++ b/README.md @@ -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. @@ -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: