From a2ac99a4ebff7040f5f521b20151a64cf340df93 Mon Sep 17 00:00:00 2001 From: ohAnd <15704728+ohAnd@users.noreply.github.com> Date: Wed, 23 Apr 2025 20:11:32 +0200 Subject: [PATCH 1/3] fix: (inverter) add version handling for Fronius GEN24 API endpoints --- src/eos_connect.py | 1 + src/interfaces/inverter_fronius.py | 34 +++++++++++++++++++++++++----- 2 files changed, 30 insertions(+), 5 deletions(-) diff --git a/src/eos_connect.py b/src/eos_connect.py index 98a2a610..956ee46c 100644 --- a/src/eos_connect.py +++ b/src/eos_connect.py @@ -97,6 +97,7 @@ def formatTime(self, record, datefmt=None): "max_pv_charge_rate": config_manager.config["inverter"]["max_pv_charge_rate"], "user": config_manager.config["inverter"]["user"], "password": config_manager.config["inverter"]["password"], + "version": config_manager.config["inverter"]["version"], } inverter_interface = FroniusWR(inverter_config) else: diff --git a/src/interfaces/inverter_fronius.py b/src/interfaces/inverter_fronius.py index 0ee9cc85..6b103d66 100644 --- a/src/interfaces/inverter_fronius.py +++ b/src/interfaces/inverter_fronius.py @@ -1,5 +1,5 @@ """ -fork from https://github.com/ohAnd/batcontrol/blob/main/src/batcontrol/inverter/fronius.py +fork from https://github.com/muexxl/batcontrol/blob/main/src/batcontrol/inverter/fronius.py This module provides a class `FroniusWR` for handling Fronius GEN24 Inverters. It includes methods for interacting with the inverter's API, managing battery @@ -75,6 +75,7 @@ def __init__(self, config: dict) -> None: self.nonce = 0 self.user = config['user'] self.password = config['password'] + self.inverter_version = config['version'] self.previous_battery_config = self.get_battery_config() self.previous_backup_power_config = None # default values @@ -163,6 +164,8 @@ def get_SOC(self): def get_battery_config(self): """ Get battery configuration from inverter and keep a backup.""" path = '/config/batteries' + if self.inverter_version == '>=1.36.5-1': + path = '/api/config/batteries' response = self.send_request(path, auth=True) if not response: logger.error( @@ -197,6 +200,8 @@ def get_powerunit_config(self, path_version='latest'): path = '/config/powerunit' else: path = '/config/setup/powerunit' + if self.inverter_version == '>=1.36.5-1': + path = '/api/config/powerunit' response = self.send_request(path, auth=True) if not response: @@ -227,6 +232,8 @@ def restore_battery_config(self): f"Unable to restore settings. Parameter {key} is missing" ) path = '/config/batteries' + if self.inverter_version == '>=1.36.5-1': + path = '/api/config/batteries' payload = json.dumps(settings) logger.info( '[Inverter] Restoring previous battery configuration: %s ', @@ -257,6 +264,8 @@ def set_allow_grid_charging(self, value: bool): else: payload = '{"HYB_EVU_CHARGEFROMGRID": false}' path = '/config/batteries' + if self.inverter_version == '>=1.36.5-1': + path = '/api/config/batteries' response = self.send_request( path, method='POST', payload=payload, auth=True) response_dict = json.loads(response.text) @@ -273,6 +282,8 @@ def set_solar_api_active(self, value: bool): else: payload = '{"SolarAPIv1Enabled": false}' path = '/config/solar_api' + if self.inverter_version == '>=1.36.5-1': + path = '/api/config/solar_api' response = self.send_request( path, method='POST', payload=payload, auth=True) response_dict = json.loads(response.text) @@ -285,6 +296,8 @@ def set_solar_api_active(self, value: bool): def set_wr_parameters(self, minsoc, maxsoc, allow_grid_charging, grid_power): """set power at grid-connection point negative values for Feed-In""" path = '/config/batteries' + if self.inverter_version == '>=1.36.5-1': + path = '/api/config/batteries' if not isinstance(allow_grid_charging , bool): raise RuntimeError( f'Expected type: bool actual type: {type(allow_grid_charging)}') @@ -331,7 +344,10 @@ def set_wr_parameters(self, minsoc, maxsoc, allow_grid_charging, grid_power): def get_time_of_use(self): """ Get time of use configuration from inverter and keep a backup.""" - response = self.send_request('/config/timeofuse', auth=True) + if self.inverter_version == '>=1.36.5-1': + response = self.send_request('/api/config/timeofuse', auth=True) + else: + response = self.send_request('/config/timeofuse', auth=True) if not response: return None @@ -454,9 +470,15 @@ def set_time_of_use(self, timeofuselist): 'timeofuse': timeofuselist } payload = json.dumps(config) - response = self.send_request( - '/config/timeofuse', method='POST', payload=payload, auth=True - ) + if self.inverter_version == '>=1.36.5-1': + response = self.send_request( + '/api/config/timeofuse', method='POST', payload=payload, auth=True + ) + else: + response = self.send_request( + '/config/timeofuse', method='POST', payload=payload, auth=True + ) + response_dict = json.loads(response.text) expected_write_successes = ['timeofuse'] for expected_write_success in expected_write_successes: @@ -684,6 +706,8 @@ def __set_em(self, mode = None, power = None): settings['HYB_EM_POWER'] = power path = '/config/batteries' + if self.inverter_version == '>=1.36.5-1': + path = '/api/config/batteries' payload = json.dumps(settings) logger.info( '[Inverter] Setting EM mode %s , power %s', From d78437432a04ee10b0ede2df13be84a4486a9d5d Mon Sep 17 00:00:00 2001 From: ohAnd <15704728+ohAnd@users.noreply.github.com> Date: Fri, 25 Apr 2025 13:09:13 +0200 Subject: [PATCH 2/3] feat: (inverter) refactor version handling and add Docker workflow for image build and push --- .github/workflows/docker_feature.yml | 36 +++++++++++ src/eos_connect.py | 1 - src/interfaces/inverter_fronius.py | 89 +++++++++++++++++----------- 3 files changed, 90 insertions(+), 36 deletions(-) create mode 100644 .github/workflows/docker_feature.yml diff --git a/.github/workflows/docker_feature.yml b/.github/workflows/docker_feature.yml new file mode 100644 index 00000000..d4d24452 --- /dev/null +++ b/.github/workflows/docker_feature.yml @@ -0,0 +1,36 @@ +name: feature - build image and push +# This workflow builds and pushes a Docker image to GitHub Container Registry. +# It is triggered on pushes to branches other than "main" and "develop", and can also be triggered manually. +# The image is tagged with the branch name of the push event. +# The workflow uses the GITHUB_TOKEN secret for authentication with GitHub Container Registry. + +on: + push: + branches-ignore: + - "main" + - "develop" + workflow_dispatch: # allows manual triggering of the workflow + +jobs: + publish_image: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Convert repository owner to lowercase + run: echo "owner=$(echo '${{ github.repository_owner }}' | tr '[:upper:]' '[:lower:]')" >> $GITHUB_ENV + + - name: Build image + run: docker build -t ghcr.io/${{ env.owner }}/eos_connect:feature . + + # Only run tagging and pushing tasks for "push" events + - name: Log in to GitHub Container Registry + run: echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u ${{ github.actor }} --password-stdin + + - name: Tag image with develop version + run: docker tag ghcr.io/${{ env.owner }}/eos_connect:feature ghcr.io/${{ env.owner }}/eos_connect:feature_dev_${{ github.ref_name }} + + - name: Push Docker image to GitHub Container Registry + run: | + docker push ghcr.io/${{ env.owner }}/eos_connect:feature_dev_${{ github.ref_name }} diff --git a/src/eos_connect.py b/src/eos_connect.py index 956ee46c..98a2a610 100644 --- a/src/eos_connect.py +++ b/src/eos_connect.py @@ -97,7 +97,6 @@ def formatTime(self, record, datefmt=None): "max_pv_charge_rate": config_manager.config["inverter"]["max_pv_charge_rate"], "user": config_manager.config["inverter"]["user"], "password": config_manager.config["inverter"]["password"], - "version": config_manager.config["inverter"]["version"], } inverter_interface = FroniusWR(inverter_config) else: diff --git a/src/interfaces/inverter_fronius.py b/src/interfaces/inverter_fronius.py index 6b103d66..b3013961 100644 --- a/src/interfaces/inverter_fronius.py +++ b/src/interfaces/inverter_fronius.py @@ -75,7 +75,16 @@ def __init__(self, config: dict) -> None: self.nonce = 0 self.user = config['user'] self.password = config['password'] - self.inverter_version = config['version'] + self.inverter_sw_revision = { + 'major': 0, + 'minor': 0, + 'patch': 0, + 'build': 0 + } + self.api_praefix = '' # default empty string + self.__get_current_inverter_sw_version() + self.__set_api_praefix() + self.previous_battery_config = self.get_battery_config() self.previous_backup_power_config = None # default values @@ -163,9 +172,7 @@ def get_SOC(self): def get_battery_config(self): """ Get battery configuration from inverter and keep a backup.""" - path = '/config/batteries' - if self.inverter_version == '>=1.36.5-1': - path = '/api/config/batteries' + path = self.api_praefix + '/config/batteries' response = self.send_request(path, auth=True) if not response: logger.error( @@ -197,11 +204,9 @@ def get_powerunit_config(self, path_version='latest'): Returns: dict with backup power configuration """ if path_version == 'latest': - path = '/config/powerunit' + path = self.api_praefix + '/config/powerunit' else: path = '/config/setup/powerunit' - if self.inverter_version == '>=1.36.5-1': - path = '/api/config/powerunit' response = self.send_request(path, auth=True) if not response: @@ -231,9 +236,7 @@ def restore_battery_config(self): raise RuntimeError( f"Unable to restore settings. Parameter {key} is missing" ) - path = '/config/batteries' - if self.inverter_version == '>=1.36.5-1': - path = '/api/config/batteries' + path = self.api_praefix + '/config/batteries' payload = json.dumps(settings) logger.info( '[Inverter] Restoring previous battery configuration: %s ', @@ -263,9 +266,7 @@ def set_allow_grid_charging(self, value: bool): payload = '{"HYB_EVU_CHARGEFROMGRID": true}' else: payload = '{"HYB_EVU_CHARGEFROMGRID": false}' - path = '/config/batteries' - if self.inverter_version == '>=1.36.5-1': - path = '/api/config/batteries' + path = self.api_praefix + '/config/batteries' response = self.send_request( path, method='POST', payload=payload, auth=True) response_dict = json.loads(response.text) @@ -281,9 +282,7 @@ def set_solar_api_active(self, value: bool): payload = '{"SolarAPIv1Enabled": true}' else: payload = '{"SolarAPIv1Enabled": false}' - path = '/config/solar_api' - if self.inverter_version == '>=1.36.5-1': - path = '/api/config/solar_api' + path = self.api_praefix + '/config/solar_api' response = self.send_request( path, method='POST', payload=payload, auth=True) response_dict = json.loads(response.text) @@ -295,9 +294,7 @@ def set_solar_api_active(self, value: bool): def set_wr_parameters(self, minsoc, maxsoc, allow_grid_charging, grid_power): """set power at grid-connection point negative values for Feed-In""" - path = '/config/batteries' - if self.inverter_version == '>=1.36.5-1': - path = '/api/config/batteries' + path = self.api_praefix + '/config/batteries' if not isinstance(allow_grid_charging , bool): raise RuntimeError( f'Expected type: bool actual type: {type(allow_grid_charging)}') @@ -344,10 +341,7 @@ def set_wr_parameters(self, minsoc, maxsoc, allow_grid_charging, grid_power): def get_time_of_use(self): """ Get time of use configuration from inverter and keep a backup.""" - if self.inverter_version == '>=1.36.5-1': - response = self.send_request('/api/config/timeofuse', auth=True) - else: - response = self.send_request('/config/timeofuse', auth=True) + response = self.send_request(self.api_praefix + '/config/timeofuse', auth=True) if not response: return None @@ -470,15 +464,9 @@ def set_time_of_use(self, timeofuselist): 'timeofuse': timeofuselist } payload = json.dumps(config) - if self.inverter_version == '>=1.36.5-1': - response = self.send_request( - '/api/config/timeofuse', method='POST', payload=payload, auth=True - ) - else: - response = self.send_request( - '/config/timeofuse', method='POST', payload=payload, auth=True - ) - + response = self.send_request( + self.api_praefix + '/config/timeofuse', method='POST', payload=payload, auth=True + ) response_dict = json.loads(response.text) expected_write_successes = ['timeofuse'] for expected_write_success in expected_write_successes: @@ -705,9 +693,7 @@ def __set_em(self, mode = None, power = None): if power is not None: settings['HYB_EM_POWER'] = power - path = '/config/batteries' - if self.inverter_version == '>=1.36.5-1': - path = '/api/config/batteries' + path = self.api_praefix + '/config/batteries' payload = json.dumps(settings) logger.info( '[Inverter] Setting EM mode %s , power %s', @@ -745,6 +731,39 @@ def shutdown(self): self.restore_time_of_use_config() self.logout() + def __get_current_inverter_sw_version(self): + """Get the current version of the inverter.""" + path = '/status/version' + response = self.send_request(path) + if not response: + logger.error( + '[Inverter] Failed to get current version. Returning default value' + ) + return 99.0 + result = json.loads(response.text) + version_string = result.get('swrevisions').get('GEN24') + version_parts = version_string.split('-')[0].split('.') + self.inverter_sw_revision = { + 'major': int(version_parts[0]), + 'minor': int(version_parts[1]), + 'patch': int(version_parts[2]), + 'build': int(version_string.split('-')[1]) + } + logger.info("[Inverter] Current sw revision: %s", self.inverter_sw_revision) + return True + + def __set_api_praefix(self): + """Set the API prefix based on the inverter version.""" + if (self.inverter_sw_revision['major'], + self.inverter_sw_revision['minor'], + self.inverter_sw_revision['patch'], + self.inverter_sw_revision['build']) < (1, 36, 5, 1): + self.api_praefix = '' + logger.info("[Inverter] Using API prefix: '%s'", self.api_praefix) + else: + self.api_praefix = '/api' + logger.info("[Inverter] Using API prefix: '%s'", self.api_praefix) + return True # def activate_mqtt(self, api_mqtt_api): # """ # Activates MQTT for the inverter. From 32c0dc53a1da910b1205a4e19fc1078b9541baa5 Mon Sep 17 00:00:00 2001 From: ohAnd <15704728+ohAnd@users.noreply.github.com> Date: Fri, 25 Apr 2025 16:41:16 +0200 Subject: [PATCH 3/3] fix: (inverter) update API path handling in login and logout methods --- src/interfaces/inverter_fronius.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/interfaces/inverter_fronius.py b/src/interfaces/inverter_fronius.py index b3013961..a88a8170 100644 --- a/src/interfaces/inverter_fronius.py +++ b/src/interfaces/inverter_fronius.py @@ -571,7 +571,7 @@ def __send_one_http_request(self, path, method='GET', payload="", def login(self): """Login to Fronius API""" logger.debug("[Inverter] Logging in") - path = '/commands/Login' + path = self.api_praefix + '/commands/Login' self.cnonce = "NaN" self.ncvalue_num = 1 self.login_attempts = 0 @@ -603,7 +603,7 @@ def login(self): def logout(self): """Logout from Fronius API""" - path = '/commands/Logout' + path = self.api_praefix + '/commands/Logout' response = self.send_request(path, auth=True) if not response: logger.warning('[Inverter] Logout failed. No response from server')