├── pyfronius ├── py.typed ├── const.py └── __init__.py ├── tests ├── __init__.py ├── web_raw │ ├── __init__.py │ ├── v0 │ │ ├── __init__.py │ │ └── web_state.py │ └── v1 │ │ ├── __init__.py │ │ ├── output │ │ └── web_state.py ├── test_structure │ ├── __init__.py │ ├── v1 │ │ ├── solar_api │ │ │ ├── GetAPIVersion.cgi │ │ │ └── v1 │ │ │ │ ├── GetLoggerLEDInfo.cgi │ │ │ │ ├── GetPowerFlowRealtimeData.fcgi │ │ │ │ ├── GetMeterRealtimeData.cgi___Scope=Device&DeviceId=0 │ │ │ │ ├── GetMeterRealtimeData.cgi___Scope=System │ │ │ │ ├── GetInverterRealtimeData.cgi___Scope=System │ │ │ │ ├── GetActiveDeviceInfo.cgi___DeviceClass=System │ │ │ │ ├── GetOhmPilotRealtimeData.cgi___Scope=System │ │ │ │ ├── GetLoggerInfo.cgi │ │ │ │ ├── GetInverterRealtimeData.cgi___Scope=Device&DeviceId=1&DataCollection=CommonInverterData │ │ │ │ ├── GetStorageRealtimeData.cgi___Scope=Device&DeviceId=0 │ │ │ │ ├── GetStorageRealtimeData.cgi___Scope=System │ │ │ │ ├── GetInverterRealtimeData.cgi___Scope=Device&DeviceId=1&DataCollection=3PInverterData │ │ │ │ └── GetInverterInfo.cgi │ │ └── .error.html │ ├── v0 │ │ ├── .error.html │ │ └── solar_api │ │ │ ├── GetInverterInfo.cgi │ │ │ ├── GetLoggerInfo.cgi │ │ │ ├── GetInverterRealtimeData.cgi___Scope=System │ │ │ ├── GetInverterRealtimeData.cgi___Scope=Device&DeviceIndex=1&DataCollection=CumulationInverterData │ │ │ └── GetInverterRealtimeData.cgi___Scope=Device&DeviceIndex=1&DataCollection=CommonInverterData │ ├── server_control.py │ └── fronius_mock_server.py ├── test_import.py ├── test_helper.py ├── util.py ├── test_web_v0.py └── test_web_v1.py ├── .pre-commit-config.yaml ├── tox.ini ├── .github └── workflows │ ├── deploy.yml │ └── build.yml ├── LICENSE.txt ├── example.py ├── pyproject.toml ├── README.md └── .gitignore /pyfronius/py.typed: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/web_raw/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/web_raw/v0/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/web_raw/v1/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/test_structure/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/test_structure/v1/solar_api/GetAPIVersion.cgi: -------------------------------------------------------------------------------- 1 | { 2 | "APIVersion": 1, 3 | "BaseURL": "/solar_api/v1/" 4 | } -------------------------------------------------------------------------------- /tests/test_import.py: -------------------------------------------------------------------------------- 1 | # Tests importing from pyfronius 2 | # uncovering any import failures hidden by coverage 3 | 4 | from pyfronius import Fronius # noqa: F401 5 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/astral-sh/ruff-pre-commit 3 | # Ruff version. 4 | rev: v0.9.9 5 | hooks: 6 | # Run the linter. 7 | - id: ruff 8 | args: [ --fix ] 9 | # Run the formatter. 10 | - id: ruff-format -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | skipsdist = True 3 | skip_install = True 4 | 5 | [testenv] 6 | deps = 7 | flake8 8 | commands = 9 | flake8 {toxinidir} 10 | 11 | [flake8] 12 | show-source = True 13 | ignore = E123, E125, W503 14 | max-line-length = 88 15 | extend-ignore = E203, W503 16 | exclude = 17 | .tox,.git,.venv, 18 | scripts/test_import.py 19 | -------------------------------------------------------------------------------- /tests/test_structure/v0/.error.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 404 - Not Found 6 | 7 | 8 |

404 - Not Found

9 | 10 | 11 | -------------------------------------------------------------------------------- /tests/test_structure/v1/.error.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 404 - Not Found 6 | 7 | 8 |

404 - Not Found

9 | 10 | 11 | -------------------------------------------------------------------------------- /tests/test_structure/v1/solar_api/v1/GetLoggerLEDInfo.cgi: -------------------------------------------------------------------------------- 1 | { 2 | "Head" : { 3 | "RequestArguments" : {}, 4 | "Status" : { 5 | "Code" : 0, 6 | "Reason" : "", 7 | "UserMessage" : "" 8 | }, 9 | "Timestamp" : "2019-06-23T23:50:16+02:00" 10 | }, 11 | "Body" : { 12 | "Data" : { 13 | "PowerLED" : { 14 | "Color" : "green", 15 | "State" : "on" 16 | }, 17 | "SolarNetLED" : { 18 | "Color" : "green", 19 | "State" : "on" 20 | }, 21 | "SolarWebLED" : { 22 | "Color" : "none", 23 | "State" : "off" 24 | }, 25 | "WLANLED" : { 26 | "Color" : "green", 27 | "State" : "on" 28 | } 29 | } 30 | } 31 | } -------------------------------------------------------------------------------- /tests/test_structure/v0/solar_api/GetInverterInfo.cgi: -------------------------------------------------------------------------------- 1 | { 2 | "Head": { 3 | "RequestArguments": {}, 4 | "Status": { 5 | "Code": 0, 6 | "Reason": "", 7 | "UserMessage": "" 8 | }, 9 | "Timestamp": "2019-06-12T15:31:02+02:00" 10 | }, 11 | "Body": { 12 | "Data": { 13 | "1": { 14 | "DT": 192, 15 | "PVPower": 5000, 16 | "UniqueID": "123456", 17 | "ErrorCode": 0, 18 | "StatusCode": 7 19 | }, 20 | "2": { 21 | "DT": 192, 22 | "PVPower": 5000, 23 | "UniqueID": "234567", 24 | "ErrorCode": 0, 25 | "StatusCode": 7 26 | } 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /tests/test_structure/v1/solar_api/v1/GetPowerFlowRealtimeData.fcgi: -------------------------------------------------------------------------------- 1 | { 2 | "Head" : { 3 | "RequestArguments" : {}, 4 | "Status" : { 5 | "Code" : 0, 6 | "Reason" : "", 7 | "UserMessage" : "" 8 | }, 9 | "Timestamp" : "2019-01-10T23:33:12+01:00" 10 | }, 11 | "Body" : { 12 | "Data" : { 13 | "Site" : { 14 | "Mode" : "vague-meter", 15 | "P_Grid" : 367.722145, 16 | "P_Load" : -367.722145, 17 | "P_Akku" : null, 18 | "P_PV" : null, 19 | "E_Day" : 0, 20 | "E_Year" : 12400.100586, 21 | "E_Total" : 26213502, 22 | "Meter_Location" : "load" 23 | }, 24 | "Inverters" : { 25 | "1" : { 26 | "DT" : 123, 27 | "P" : null 28 | } 29 | } 30 | } 31 | } 32 | } -------------------------------------------------------------------------------- /tests/test_structure/v1/solar_api/v1/GetMeterRealtimeData.cgi___Scope=Device&DeviceId=0: -------------------------------------------------------------------------------- 1 | { 2 | "Head" : { 3 | "RequestArguments" : { 4 | "DeviceClass" : "Meter", 5 | "DeviceId" : "0", 6 | "Scope" : "Device" 7 | }, 8 | "Status" : { 9 | "Code" : 0, 10 | "Reason" : "", 11 | "UserMessage" : "" 12 | }, 13 | "Timestamp" : "2019-01-10T23:33:14+01:00" 14 | }, 15 | "Body" : { 16 | "Data" : { 17 | "Details" : { 18 | "Serial" : "", 19 | "Model" : "", 20 | "Manufacturer" : "Fronius" 21 | }, 22 | "TimeStamp" : 1547159593, 23 | "Enable" : 1, 24 | "Visible" : 1, 25 | "PowerReal_P_Sum" : -367.722145, 26 | "Meter_Location_Current" : 1, 27 | "EnergyReal_WAC_Minus_Relative" : 17 28 | } 29 | } 30 | } -------------------------------------------------------------------------------- /tests/test_structure/v1/solar_api/v1/GetMeterRealtimeData.cgi___Scope=System: -------------------------------------------------------------------------------- 1 | { 2 | "Head" : { 3 | "RequestArguments" : { 4 | "DeviceClass" : "Meter", 5 | "Scope" : "System" 6 | }, 7 | "Status" : { 8 | "Code" : 0, 9 | "Reason" : "", 10 | "UserMessage" : "" 11 | }, 12 | "Timestamp" : "2019-01-10T23:33:13+01:00" 13 | }, 14 | "Body" : { 15 | "Data" : { 16 | "0" : { 17 | "Details" : { 18 | "Serial" : "", 19 | "Model" : "", 20 | "Manufacturer" : "Fronius" 21 | }, 22 | "TimeStamp" : 1547159592, 23 | "Enable" : 1, 24 | "Visible" : 1, 25 | "PowerReal_P_Sum" : -367.722145, 26 | "Meter_Location_Current" : 1, 27 | "EnergyReal_WAC_Minus_Relative" : 17 28 | } 29 | } 30 | } 31 | } -------------------------------------------------------------------------------- /tests/test_structure/v0/solar_api/GetLoggerInfo.cgi: -------------------------------------------------------------------------------- 1 | { 2 | "Body" : { 3 | "LoggerInfo" : { 4 | "CO2Factor" : 0.52999997138977051, 5 | "CO2Unit" : "kg", 6 | "CashCurrency" : "EUR", 7 | "CashFactor" : 0.075999997556209564, 8 | "DefaultLanguage" : "en", 9 | "HWVersion" : "2.4E", 10 | "SWVersion" : "3.18.7-1", 11 | "TimezoneLocation" : "Vienna", 12 | "TimezoneName" : "CEST", 13 | "UTCOffset" : 7200, 14 | "UniqueID" : "240.123456" 15 | } 16 | }, 17 | "Head" : { 18 | "RequestArguments" : {}, 19 | "Status" : { 20 | "Code" : 0, 21 | "Reason" : "", 22 | "UserMessage" : "" 23 | }, 24 | "Timestamp" : "2021-08-17T14:36:40+02:00" 25 | } 26 | } -------------------------------------------------------------------------------- /tests/test_structure/v1/solar_api/v1/GetInverterRealtimeData.cgi___Scope=System: -------------------------------------------------------------------------------- 1 | { 2 | "Head" : { 3 | "RequestArguments" : { 4 | "DataCollection" : "", 5 | "Scope" : "System" 6 | }, 7 | "Status" : { 8 | "Code" : 0, 9 | "Reason" : "", 10 | "UserMessage" : "" 11 | }, 12 | "Timestamp" : "2019-01-10T23:33:16+01:00" 13 | }, 14 | "Body" : { 15 | "Data" : { 16 | "PAC" : { 17 | "Unit" : "W", 18 | "Values" : { 19 | "1" : 0 20 | } 21 | }, 22 | "DAY_ENERGY" : { 23 | "Unit" : "Wh", 24 | "Values" : { 25 | "1" : 0 26 | } 27 | }, 28 | "YEAR_ENERGY" : { 29 | "Unit" : "Wh", 30 | "Values" : { 31 | "1" : 12400 32 | } 33 | }, 34 | "TOTAL_ENERGY" : { 35 | "Unit" : "Wh", 36 | "Values" : { 37 | "1" : 26213502 38 | } 39 | } 40 | } 41 | } 42 | } -------------------------------------------------------------------------------- /tests/test_structure/v1/solar_api/v1/GetActiveDeviceInfo.cgi___DeviceClass=System: -------------------------------------------------------------------------------- 1 | { 2 | "Body" : { 3 | "Data" : { 4 | "Inverter" : { 5 | "1" : { 6 | "DT" : 122, 7 | "Serial" : "30412345" 8 | } 9 | }, 10 | "Meter" : { 11 | "0" : { 12 | "DT" : -1, 13 | "Serial" : "18412345" 14 | } 15 | }, 16 | "Ohmpilot" : {}, 17 | "SensorCard" : {}, 18 | "Storage" : {}, 19 | "StringControl" : {} 20 | } 21 | }, 22 | "Head" : { 23 | "RequestArguments" : { 24 | "DeviceClass" : "System" 25 | }, 26 | "Status" : { 27 | "Code" : 0, 28 | "Reason" : "", 29 | "UserMessage" : "" 30 | }, 31 | "Timestamp" : "2021-08-17T14:12:17+02:00" 32 | } 33 | } -------------------------------------------------------------------------------- /tests/test_structure/v1/solar_api/v1/GetOhmPilotRealtimeData.cgi___Scope=System: -------------------------------------------------------------------------------- 1 | { 2 | "Body": { 3 | "Data": { 4 | "0": { 5 | "CodeOfError": 926, 6 | "CodeOfState": 0, 7 | "Details": { 8 | "Hardware": "3", 9 | "Manufacturer": "Fronius", 10 | "Model": "Ohmpilot", 11 | "Serial": "28136344", 12 | "Software": "1.0.19-1" 13 | }, 14 | "EnergyReal_WAC_Sum_Consumed": 2964307, 15 | "PowerReal_PAC_Sum": 0, 16 | "Temperature_Channel_1": 23.9 17 | } 18 | } 19 | }, 20 | "Head": { 21 | "RequestArguments": { 22 | "DeviceClass": "OhmPilot", 23 | "Scope": "System" 24 | }, 25 | "Status": { 26 | "Code": 0, 27 | "Reason": "", 28 | "UserMessage": "" 29 | }, 30 | "Timestamp": "2019-06-24T10:10:44+02:00" 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /tests/test_helper.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | # general requirements 5 | import unittest 6 | 7 | # for the tests 8 | from .web_raw.v1 import web_state 9 | from pyfronius import Fronius 10 | 11 | 12 | class FroniusHelperTest(unittest.TestCase): 13 | def test_error_code(self): 14 | res = web_state.GET_INVERTER_REALTIME_DATA_SCOPE_DEVICE 15 | self.assertEqual(Fronius.error_code(res), 0) 16 | res = web_state.GET_STORAGE_REALTIME_DATA_SCOPE_DEVICE_UNSUPPORTED 17 | self.assertEqual(Fronius.error_code(res), 255) 18 | 19 | def test_error_reason(self): 20 | res = web_state.GET_INVERTER_REALTIME_DATA_SCOPE_DEVICE 21 | self.assertEqual(Fronius.error_reason(res), "") 22 | res = web_state.GET_STORAGE_REALTIME_DATA_SCOPE_DEVICE_UNSUPPORTED 23 | self.assertEqual(Fronius.error_reason(res), "Storages are not supported") 24 | -------------------------------------------------------------------------------- /tests/test_structure/v1/solar_api/v1/GetLoggerInfo.cgi: -------------------------------------------------------------------------------- 1 | { 2 | "Body" : { 3 | "LoggerInfo" : { 4 | "CO2Factor" : 0.52999997138977051, 5 | "CO2Unit" : "kg", 6 | "CashCurrency" : "EUR", 7 | "CashFactor" : 0.075999997556209564, 8 | "DefaultLanguage" : "en", 9 | "DeliveryFactor" : 0.15000000596046448, 10 | "HWVersion" : "2.4E", 11 | "PlatformID" : "wilma", 12 | "ProductID" : "fronius-datamanager-card", 13 | "SWVersion" : "3.18.7-1", 14 | "TimezoneLocation" : "Vienna", 15 | "TimezoneName" : "CEST", 16 | "UTCOffset" : 7200, 17 | "UniqueID" : "240.123456" 18 | } 19 | }, 20 | "Head" : { 21 | "RequestArguments" : {}, 22 | "Status" : { 23 | "Code" : 0, 24 | "Reason" : "", 25 | "UserMessage" : "" 26 | }, 27 | "Timestamp" : "2021-08-17T14:36:40+02:00" 28 | } 29 | } -------------------------------------------------------------------------------- /tests/test_structure/v1/solar_api/v1/GetInverterRealtimeData.cgi___Scope=Device&DeviceId=1&DataCollection=CommonInverterData: -------------------------------------------------------------------------------- 1 | { 2 | "Head" : { 3 | "RequestArguments" : { 4 | "DataCollection" : "CommonInverterData", 5 | "DeviceClass" : "Inverter", 6 | "DeviceId" : "1", 7 | "Scope" : "Device" 8 | }, 9 | "Status" : { 10 | "Code" : 0, 11 | "Reason" : "", 12 | "UserMessage" : "" 13 | }, 14 | "Timestamp" : "2019-01-10T23:33:15+01:00" 15 | }, 16 | "Body" : { 17 | "Data" : { 18 | "DAY_ENERGY" : { 19 | "Value" : 0, 20 | "Unit" : "Wh" 21 | }, 22 | "TOTAL_ENERGY" : { 23 | "Value" : 26213502, 24 | "Unit" : "Wh" 25 | }, 26 | "YEAR_ENERGY" : { 27 | "Value" : 12400.1, 28 | "Unit" : "Wh" 29 | }, 30 | "DeviceStatus" : { 31 | "StatusCode" : 3, 32 | "MgmtTimerRemainingTime" : -1, 33 | "ErrorCode" : 523, 34 | "LEDColor" : 1, 35 | "LEDState" : 0, 36 | "StateToReset" : true 37 | } 38 | } 39 | } 40 | } -------------------------------------------------------------------------------- /.github/workflows/deploy.yml: -------------------------------------------------------------------------------- 1 | name: Deploy 2 | 3 | on: 4 | push: 5 | tags: 6 | - "*.*.*" 7 | 8 | jobs: 9 | deploy: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v4 13 | 14 | - name: Set up Python 15 | uses: actions/setup-python@v5 16 | with: 17 | python-version: "3.13" 18 | 19 | - name: Install dependencies 20 | run: | 21 | python -m pip install --upgrade pip 22 | python -m pip install build twine 23 | 24 | - name: Publish to PyPI 25 | env: 26 | TWINE_USERNAME: __token__ 27 | TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN }} 28 | run: | 29 | python -m build 30 | twine upload --skip-existing dist/* 31 | 32 | - name: Create GitHub Release 33 | uses: softprops/action-gh-release@v1 34 | with: 35 | files: dist/* 36 | generate_release_notes: true 37 | env: 38 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 39 | -------------------------------------------------------------------------------- /tests/test_structure/v1/solar_api/v1/GetStorageRealtimeData.cgi___Scope=Device&DeviceId=0: -------------------------------------------------------------------------------- 1 | { 2 | "Body" : { 3 | "Data" : { 4 | "Controller" : { 5 | "Capacity_Maximum" : 11520, 6 | "DesignedCapacity" : 11520, 7 | "Details" : { 8 | "Manufacturer" : "BYD", 9 | "Model" : "BYD Battery-Box HV", 10 | "Serial" : "123456789-12345" 11 | }, 12 | "Enable" : 1, 13 | "StateOfCharge_Relative" : 8, 14 | "Status_BatteryCell" : 0, 15 | "Temperature_Cell" : 19.699999999999999, 16 | "TimeStamp" : 1641402599, 17 | "Voltage_DC" : 0 18 | }, 19 | "Modules" : [] 20 | } 21 | }, 22 | "Head" : { 23 | "RequestArguments" : { 24 | "DeviceClass" : "Storage", 25 | "DeviceId" : "0", 26 | "Scope" : "Device" 27 | }, 28 | "Status" : { 29 | "Code" : 0, 30 | "Reason" : "", 31 | "UserMessage" : "" 32 | }, 33 | "Timestamp" : "2022-01-05T18:10:02+01:00" 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /tests/util.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import contextlib 3 | import socket 4 | 5 | import aiounittest 6 | from aiounittest import async_test 7 | 8 | ADDRESS = "localhost" 9 | 10 | 11 | class AsyncTestCaseSetup(aiounittest.AsyncTestCase): 12 | async def setUp(self): 13 | pass 14 | 15 | async def tearDown(self): 16 | pass 17 | 18 | def __getattribute__(self, name): 19 | attr = object.__getattribute__(self, name) 20 | if name.startswith("test_") and asyncio.iscoroutinefunction(attr): 21 | 22 | async def wrapped_attr(): 23 | await self.setUp() 24 | await attr() 25 | await self.tearDown() 26 | 27 | res = async_test(wrapped_attr, loop=self.get_event_loop()) 28 | return res 29 | else: 30 | return attr 31 | 32 | 33 | def _get_unused_port() -> int: 34 | """Return an unused localhost port for negative connection tests.""" 35 | with contextlib.closing(socket.socket(socket.AF_INET, socket.SOCK_STREAM)) as sock: 36 | sock.bind((ADDRESS, 0)) 37 | return sock.getsockname()[1] 38 | -------------------------------------------------------------------------------- /tests/test_structure/v0/solar_api/GetInverterRealtimeData.cgi___Scope=System: -------------------------------------------------------------------------------- 1 | { 2 | "Head": { 3 | "RequestArguments": { 4 | "Scope": "System" 5 | }, 6 | "Status": { 7 | "Code": 0, 8 | "Reason": "", 9 | "UserMessage": "" 10 | }, 11 | "Timestamp": "2020-09-18T14:13:49-07:00" 12 | }, 13 | "Body": { 14 | "Data": { 15 | "PAC": { 16 | "Unit": "W", 17 | "Values": { 18 | "1": 1764 19 | } 20 | }, 21 | "DAY_ENERGY": { 22 | "Unit": "Wh", 23 | "Values": { 24 | "1": 6000 25 | } 26 | }, 27 | "YEAR_ENERGY": { 28 | "Unit": "Wh", 29 | "Values": { 30 | "1": 3310000 31 | } 32 | }, 33 | "TOTAL_ENERGY": { 34 | "Unit": "Wh", 35 | "Values": { 36 | "1": 35611000 37 | } 38 | } 39 | } 40 | } 41 | } -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2020 Niels Mündler, Gerrit Beine 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of 4 | this software and associated documentation files (the "Software"), to deal in 5 | the Software without restriction, including without limitation the rights to 6 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies 7 | of the Software, and to permit persons to whom the Software is furnished to do 8 | so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. 20 | -------------------------------------------------------------------------------- /tests/test_structure/v1/solar_api/v1/GetStorageRealtimeData.cgi___Scope=System: -------------------------------------------------------------------------------- 1 | { 2 | "Body" : { 3 | "Data" : { 4 | "0" : { 5 | "Controller" : { 6 | "Capacity_Maximum" : 11520, 7 | "DesignedCapacity" : 11520, 8 | "Details" : { 9 | "Manufacturer" : "BYD", 10 | "Model" : "BYD Battery-Box HV", 11 | "Serial" : "123456789-12345" 12 | }, 13 | "Enable" : 1, 14 | "StateOfCharge_Relative" : 7.9000000000000004, 15 | "Status_BatteryCell" : 0, 16 | "Temperature_Cell" : 19.550000000000001, 17 | "TimeStamp" : 1641404544, 18 | "Voltage_DC" : 0 19 | }, 20 | "Modules" : [] 21 | } 22 | } 23 | }, 24 | "Head" : { 25 | "RequestArguments" : { 26 | "DeviceClass" : "Storage", 27 | "Scope" : "System" 28 | }, 29 | "Status" : { 30 | "Code" : 0, 31 | "Reason" : "", 32 | "UserMessage" : "" 33 | }, 34 | "Timestamp" : "2022-01-05T18:42:26+01:00" 35 | } 36 | } 37 | 38 | -------------------------------------------------------------------------------- /tests/test_structure/v1/solar_api/v1/GetInverterRealtimeData.cgi___Scope=Device&DeviceId=1&DataCollection=3PInverterData: -------------------------------------------------------------------------------- 1 | { 2 | "Body" : { 3 | "Data" : { 4 | "IAC_L1" : { 5 | "Unit" : "A", 6 | "Value" : 0.92972046136856079 7 | }, 8 | "IAC_L2" : { 9 | "Unit" : "A", 10 | "Value" : 0.92731237411499023 11 | }, 12 | "IAC_L3" : { 13 | "Unit" : "A", 14 | "Value" : 0.93189901113510132 15 | }, 16 | "UAC_L1" : { 17 | "Unit" : "V", 18 | "Value" : 231.87258911132812 19 | }, 20 | "UAC_L2" : { 21 | "Unit" : "V", 22 | "Value" : 231.79225158691406 23 | }, 24 | "UAC_L3" : { 25 | "Unit" : "V", 26 | "Value" : 230.89854431152344 27 | } 28 | } 29 | }, 30 | "Head" : { 31 | "RequestArguments" : { 32 | "DataCollection" : "3PInverterData", 33 | "DeviceId" : "1", 34 | "Scope" : "Device" 35 | }, 36 | "Status" : { 37 | "Code" : 0, 38 | "Reason" : "", 39 | "UserMessage" : "" 40 | }, 41 | "Timestamp" : "2025-03-30T15:54:07+00:00" 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /tests/test_structure/v0/solar_api/GetInverterRealtimeData.cgi___Scope=Device&DeviceIndex=1&DataCollection=CumulationInverterData: -------------------------------------------------------------------------------- 1 | { 2 | "Head": { 3 | "RequestArguments": { 4 | "DataCollection": "CumulationInverterData", 5 | "DeviceClass": "Inverter", 6 | "DeviceIndex": "1", 7 | "Scope": "Device" 8 | }, 9 | "Status": { 10 | "Code": 0, 11 | "Reason": "", 12 | "UserMessage": "" 13 | }, 14 | "Timestamp": "2020-09-18T14:14:56-07:00" 15 | }, 16 | "Body": { 17 | "Data": { 18 | "DAY_ENERGY": { 19 | "Value": 6000, 20 | "Unit": "Wh" 21 | }, 22 | "PAC": { 23 | "Value": 1785, 24 | "Unit": "W" 25 | }, 26 | "TOTAL_ENERGY": { 27 | "Value": 35611000, 28 | "Unit": "Wh" 29 | }, 30 | "YEAR_ENERGY": { 31 | "Value": 3310000, 32 | "Unit": "Wh" 33 | }, 34 | "DeviceStatus": { 35 | "StatusCode": 7, 36 | "MgmtTimerRemainingTime": -1, 37 | "ErrorCode": 0, 38 | "LEDColor": 2, 39 | "LEDState": 0, 40 | "StateToReset": false 41 | } 42 | } 43 | } 44 | } -------------------------------------------------------------------------------- /example.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """Basic usage example and testing of pyfronius.""" 3 | 4 | import asyncio 5 | import logging 6 | import json 7 | import sys 8 | import aiohttp 9 | 10 | import pyfronius 11 | 12 | 13 | async def main(loop, host): 14 | timeout = aiohttp.ClientTimeout(total=10) 15 | async with aiohttp.ClientSession(loop=loop, timeout=timeout) as session: 16 | fronius = pyfronius.Fronius(session, host) 17 | 18 | # use the optional fetch parameters to configure 19 | # which endpoints are acessed 20 | # NOTE: configuring the wrong devices may cause Exceptions to be thrown 21 | res = await fronius.fetch( 22 | active_device_info=True, 23 | inverter_info=True, 24 | logger_info=True, 25 | power_flow=True, 26 | system_meter=True, 27 | system_inverter=True, 28 | system_ohmpilot=True, 29 | system_storage=True, 30 | device_meter=["0"], 31 | # storage is not necessarily supported by every fronius device 32 | device_storage=["0"], 33 | device_inverter=["1"], 34 | ) 35 | for r in res: 36 | print(json.dumps(r, indent=4)) 37 | 38 | 39 | if __name__ == "__main__": 40 | logging.basicConfig(level=logging.DEBUG) 41 | loop = asyncio.get_event_loop() 42 | loop.run_until_complete(main(loop, sys.argv[1])) 43 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools>=69"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [project] 6 | name = "PyFronius" 7 | version = "0.8.1" 8 | authors = [ 9 | { name = "Niels Mündler", email = "n.muendler@web.de" }, 10 | { name = "Matthias Alphart", email = "farmio@alphart.net" }, 11 | { name = "Gerrit Beine", email = "mail@gerritbeine.de" }, 12 | ] 13 | description = "Client for Fronius SolarAPI JSON interface" 14 | license = { text = "MIT" } 15 | keywords = ["Fronius", "SolarAPI", "photovoltaics", "pv"] 16 | readme = "README.md" 17 | classifiers = [ 18 | "Programming Language :: Python :: 3.8", 19 | "Programming Language :: Python :: 3.9", 20 | "Programming Language :: Python :: 3.10", 21 | "Programming Language :: Python :: 3.11", 22 | "Programming Language :: Python :: 3.12", 23 | "Programming Language :: Python :: 3.13", 24 | "Operating System :: OS Independent", 25 | "License :: OSI Approved :: MIT License", 26 | "Development Status :: 3 - Alpha", 27 | "Intended Audience :: Developers", 28 | "Topic :: Software Development :: Object Brokering", 29 | ] 30 | dependencies = ["aiohttp"] 31 | requires-python = ">=3.8.0" 32 | 33 | [project.urls] 34 | Repository = "https://github.com/nielstron/pyfronius/" 35 | 36 | [tool.setuptools.packages.find] 37 | include = ["pyfronius*"] 38 | 39 | [tool.uv.workspace] 40 | members = [ 41 | ".", 42 | ] 43 | 44 | [tool.uv.sources] 45 | pyfronius = { workspace = true } 46 | 47 | [dependency-groups] 48 | dev = [ 49 | "aiounittest>=1.5.0", 50 | "coverage>=7.6.1", 51 | "pre-commit>=3.5.0", 52 | "pyfronius", 53 | "pytest>=8.3.5", 54 | "tox>=4.25.0", 55 | ] 56 | -------------------------------------------------------------------------------- /tests/test_structure/v1/solar_api/v1/GetInverterInfo.cgi: -------------------------------------------------------------------------------- 1 | { 2 | "Body": { 3 | "Data": { 4 | "1": { 5 | "CustomName": "Primo 8.2-1 (", 6 | "DT": 102, 7 | "ErrorCode": 0, 8 | "PVPower": 500, 9 | "Show": 1, 10 | "StatusCode": 7, 11 | "UniqueID": "38183" 12 | }, 13 | "2": { 14 | "CustomName": "Primo 5.0-1 20", 15 | "DT": 86, 16 | "ErrorCode": 0, 17 | "PVPower": 500, 18 | "Show": 1, 19 | "StatusCode": 7, 20 | "UniqueID": "16777215" 21 | }, 22 | "3": { 23 | "CustomName": "Galvo 3.1-1 20", 24 | "DT": 106, 25 | "ErrorCode": 0, 26 | "PVPower": 500, 27 | "Show": 1, 28 | "StatusCode": 7, 29 | "UniqueID": "7262" 30 | }, 31 | "55": { 32 | "CustomName": "Galvo 3.0-1 (5", 33 | "DT": 224, 34 | "ErrorCode": 0, 35 | "PVPower": 500, 36 | "Show": 1, 37 | "StatusCode": 7, 38 | "UniqueID": "100372" 39 | }, 40 | "240": { 41 | "CustomName": "tr-3pn-01", 42 | "DT": 1, 43 | "PVPower": 0, 44 | "Show": 1, 45 | "StatusCode": "Running", 46 | "UniqueID": "29301000987160033" 47 | } 48 | } 49 | }, 50 | "Head": { 51 | "RequestArguments": {}, 52 | "Status": { 53 | "Code": 0, 54 | "Reason": "", 55 | "UserMessage": "" 56 | }, 57 | "Timestamp": "2019-06-12T15:31:02+02:00" 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build and Test 2 | 3 | on: 4 | push: 5 | branches: 6 | - "**" 7 | pull_request: 8 | branches: 9 | - "**" 10 | 11 | jobs: 12 | test: 13 | runs-on: ${{ matrix.os }} 14 | strategy: 15 | matrix: 16 | os: [ubuntu-latest] 17 | python-version: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13"] 18 | include: 19 | - os: windows-latest 20 | python-version: "3.12" 21 | steps: 22 | - uses: actions/checkout@v4 23 | 24 | - name: Set up Python ${{ matrix.python-version }} 25 | uses: actions/setup-python@v5 26 | with: 27 | python-version: ${{ matrix.python-version }} 28 | 29 | - name: Install uv 30 | uses: astral-sh/setup-uv@v7 31 | 32 | - name: Install dependencies 33 | run: | 34 | uv sync --dev 35 | 36 | - name: Run tests 37 | run: | 38 | uv run tox 39 | uv run tests/test_import.py 40 | uv run coverage run --source=pyfronius --module pytest 41 | 42 | - name: Upload coverage data to coveralls.io 43 | run: | 44 | uv pip install --upgrade coveralls || true 45 | uv run coveralls || true 46 | env: 47 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 48 | COVERALLS_FLAG_NAME: ${{ matrix.os }}-${{ matrix.python-version }} 49 | COVERALLS_PARALLEL: true 50 | 51 | coveralls: 52 | name: Indicate completion to coveralls.io 53 | needs: [test] 54 | runs-on: ubuntu-latest 55 | container: python:3-slim 56 | steps: 57 | - name: Install coveralls 58 | run: pip3 install --upgrade coveralls 59 | - name: Finished 60 | run: coveralls --finish || true 61 | env: 62 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} -------------------------------------------------------------------------------- /tests/test_structure/server_control.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | import threading 5 | 6 | 7 | class Server: 8 | def __init__(self, server): 9 | """ 10 | Expects subclass of TCPServer as argument 11 | """ 12 | self._server = server 13 | self._server_started_event = threading.Event() 14 | self._server_running = False 15 | 16 | def _run_server(self): 17 | print("Server started, serving on port {}".format(self.get_port())) 18 | 19 | # notify about start 20 | self._server_started_event.set() 21 | self._server_running = True 22 | 23 | try: 24 | self._server.serve_forever() 25 | finally: 26 | self._cleanup_server() 27 | 28 | def _cleanup_server(self): 29 | self._server_running = False 30 | self._server.server_close() 31 | # Here, server was stopped 32 | print("Server stopped") 33 | 34 | def stop_server(self): 35 | """ 36 | Close server forcibly 37 | :return: 38 | """ 39 | print("Stopping server") 40 | if self._server_running: 41 | self._server.shutdown() 42 | 43 | def start_server(self, timeout=10): 44 | """ 45 | Start server thread as daemon 46 | As such the program will automatically close the thread on exit of all 47 | non-daemon threads 48 | :return: 49 | """ 50 | self._server_started_event.clear() 51 | # start webserver as daemon => will automatically be closed when 52 | # non-daemon threads are closed 53 | t = threading.Thread(target=self._run_server, daemon=True) 54 | # Start webserver 55 | t.start() 56 | # wait (non-busy) for successful start 57 | self._server_started_event.wait(timeout=timeout) 58 | 59 | def get_port(self): 60 | return self._server.server_address[1] 61 | -------------------------------------------------------------------------------- /tests/test_structure/v0/solar_api/GetInverterRealtimeData.cgi___Scope=Device&DeviceIndex=1&DataCollection=CommonInverterData: -------------------------------------------------------------------------------- 1 | { 2 | "Head": { 3 | "RequestArguments": { 4 | "DataCollection": "CommonInverterData", 5 | "DeviceClass": "Inverter", 6 | "DeviceIndex": "1", 7 | "Scope": "Device" 8 | }, 9 | "Status": { 10 | "Code": 0, 11 | "Reason": "", 12 | "UserMessage": "" 13 | }, 14 | "Timestamp": "2020-09-18T14:14:24-07:00" 15 | }, 16 | "Body": { 17 | "Data": { 18 | "DAY_ENERGY": { 19 | "Value": 6000, 20 | "Unit": "Wh" 21 | }, 22 | "FAC": { 23 | "Value": 60, 24 | "Unit": "Hz" 25 | }, 26 | "IAC": { 27 | "Value": 7.31, 28 | "Unit": "A" 29 | }, 30 | "IDC": { 31 | "Value": 6.54, 32 | "Unit": "A" 33 | }, 34 | "PAC": { 35 | "Value": 1762, 36 | "Unit": "W" 37 | }, 38 | "TOTAL_ENERGY": { 39 | "Value": 35611000, 40 | "Unit": "Wh" 41 | }, 42 | "UAC": { 43 | "Value": 241, 44 | "Unit": "V" 45 | }, 46 | "UDC": { 47 | "Value": 286, 48 | "Unit": "V" 49 | }, 50 | "YEAR_ENERGY": { 51 | "Value": 3310000, 52 | "Unit": "Wh" 53 | }, 54 | "DeviceStatus": { 55 | "StatusCode": 7, 56 | "MgmtTimerRemainingTime": -1, 57 | "ErrorCode": 0, 58 | "LEDColor": 2, 59 | "LEDState": 0, 60 | "StateToReset": false 61 | } 62 | } 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # PyFronius - a very basic Fronius python bridge 2 | [![Build and Test](https://github.com/nielstron/pyfronius/actions/workflows/build.yml/badge.svg)](https://github.com/nielstron/pyfronius/actions/workflows/build.yml) 3 | [![Coverage Status](https://coveralls.io/repos/github/nielstron/pyfronius/badge.svg?branch=master)](https://coveralls.io/github/nielstron/pyfronius?branch=master) 4 | [![PyPI version](https://badge.fury.io/py/PyFronius.svg)](https://pypi.org/project/pyfronius/) 5 | ![PyPI - Python Version](https://img.shields.io/pypi/pyversions/PyFronius.svg) 6 | [![PyPI - Status](https://img.shields.io/pypi/status/PyFronius.svg)](https://pypi.org/project/pyfronius/) 7 | 8 | A package that connects to a Fronius device in the local network and provides data 9 | that is provided via the JSON API of the Fronius. 10 | This includes the grid consumption, grid return, photovoltaic production 11 | and many more details on the status of the local power supply. 12 | 13 | > This package is looking for maintainers. I do not own a Fronius device anymore and cannot test the package. 14 | > If you are interested in maintaining this package, please contact me. 15 | 16 | ## Features 17 | 18 | The package supports the following data provided by Fronius devices: 19 | 20 | - Power Flow (System scope) 21 | - Meter (System and Device scope) 22 | - Inverter (System and Device scope) 23 | - Storage (System and Device scope, Experimental) 24 | - Active Devices 25 | - Logger Information 26 | - Inverter Information 27 | 28 | The package currently supportes the Fronius API V1 and V0 29 | and aims to support as many different device types as possible (Hybrid, GEN24,...). 30 | 31 | ## Contributing 32 | 33 | Support may be enhanced based on the official documentation ([V1](https://www.fronius.com/~/downloads/Solar%20Energy/Operating%20Instructions/42%2C0410%2C2012.pdf), [V0](https://www.fronius.com/~/downloads/Solar%20Energy/Operating%20Instructions/42,0410,2011.pdf)). 34 | Pull requests are very welcome. 35 | 36 | If you own a Fronius device, feel free to provide us with raw data returned 37 | by fetching the API endpoints manually. 38 | -------------------------------------------------------------------------------- /tests/web_raw/v0/web_state.py: -------------------------------------------------------------------------------- 1 | GET_INVERTER_REALTIME_DATA_SCOPE_DEVICE = { 2 | "timestamp": {"value": "2020-09-18T14:14:24-07:00"}, 3 | "status": {"Code": 0, "Reason": "", "UserMessage": ""}, 4 | "energy_day": {"value": 6000, "unit": "Wh"}, 5 | "energy_total": {"value": 35611000, "unit": "Wh"}, 6 | "energy_year": {"value": 3310000, "unit": "Wh"}, 7 | "frequency_ac": {"value": 60, "unit": "Hz"}, 8 | "current_ac": {"value": 7.31, "unit": "A"}, 9 | "current_dc": {"value": 6.54, "unit": "A"}, 10 | "power_ac": {"value": 1762, "unit": "W"}, 11 | "voltage_ac": {"value": 241, "unit": "V"}, 12 | "voltage_dc": {"value": 286, "unit": "V"}, 13 | "status_code": {"value": 7}, 14 | "error_code": {"value": 0}, 15 | "led_state": {"value": 0}, 16 | "led_color": {"value": 2}, 17 | } 18 | 19 | GET_INVERTER_REALTIME_DATA_SYSTEM = { 20 | "timestamp": {"value": "2020-09-18T14:13:49-07:00"}, 21 | "status": {"Code": 0, "Reason": "", "UserMessage": ""}, 22 | "energy_day": {"value": 6000, "unit": "Wh"}, 23 | "energy_total": {"value": 35611000, "unit": "Wh"}, 24 | "energy_year": {"value": 3310000, "unit": "Wh"}, 25 | "power_ac": {"value": 1764, "unit": "W"}, 26 | "inverters": { 27 | "1": { 28 | "energy_day": {"value": 6000, "unit": "Wh"}, 29 | "energy_total": {"value": 35611000, "unit": "Wh"}, 30 | "energy_year": {"value": 3310000, "unit": "Wh"}, 31 | "power_ac": {"value": 1764, "unit": "W"}, 32 | } 33 | }, 34 | } 35 | GET_LOGGER_INFO = { 36 | "timestamp": {"value": "2021-08-17T14:36:40+02:00"}, 37 | "status": {"Code": 0, "Reason": "", "UserMessage": ""}, 38 | "co2_factor": {"value": 0.5299999713897705, "unit": "kg/kWh"}, 39 | "cash_factor": {"value": 0.07599999755620956, "unit": "EUR/kWh"}, 40 | "hardware_version": {"value": "2.4E"}, 41 | "software_version": {"value": "3.18.7-1"}, 42 | "time_zone_location": {"value": "Vienna"}, 43 | "time_zone": {"value": "CEST"}, 44 | "utc_offset": {"value": 7200}, 45 | "unique_identifier": {"value": "240.123456"}, 46 | } 47 | GET_INVERTER_INFO = { 48 | "timestamp": {"value": "2019-06-12T15:31:02+02:00"}, 49 | "status": {"Code": 0, "Reason": "", "UserMessage": ""}, 50 | "inverters": [ 51 | { 52 | "device_id": {"value": "1"}, 53 | "device_type": { 54 | "value": 192, 55 | "manufacturer": "Fronius", 56 | "model": "IG TL 5.0", 57 | }, 58 | "pv_power": { 59 | "value": 5000, 60 | "unit": "W", 61 | }, 62 | "unique_id": {"value": "123456"}, 63 | "error_code": {"value": 0}, 64 | "status_code": {"value": 7}, 65 | }, 66 | { 67 | "device_id": {"value": "2"}, 68 | "device_type": { 69 | "value": 192, 70 | "manufacturer": "Fronius", 71 | "model": "IG TL 5.0", 72 | }, 73 | "pv_power": { 74 | "value": 5000, 75 | "unit": "W", 76 | }, 77 | "unique_id": {"value": "234567"}, 78 | "error_code": {"value": 0}, 79 | "status_code": {"value": 7}, 80 | }, 81 | ], 82 | } 83 | -------------------------------------------------------------------------------- /tests/test_structure/fronius_mock_server.py: -------------------------------------------------------------------------------- 1 | import os 2 | import urllib.parse 3 | from http.server import BaseHTTPRequestHandler, SimpleHTTPRequestHandler, HTTPServer 4 | from typing import Tuple, Callable 5 | 6 | try: 7 | from http import HTTPStatus 8 | except ImportError: 9 | # Backwards compatability 10 | import http.client as HTTPStatus 11 | import posixpath 12 | from pathlib import Path 13 | 14 | SERVER_DIR = Path(__file__).parent or Path(".") 15 | 16 | 17 | class FroniusServer(HTTPServer): 18 | def __init__( 19 | self, 20 | server_address: Tuple[str, int], 21 | RequestHandlerClass: Callable[..., BaseHTTPRequestHandler], 22 | api_version: int, 23 | ): 24 | super().__init__(server_address, RequestHandlerClass) 25 | self.api_version = api_version 26 | 27 | 28 | class FroniusRequestHandler(SimpleHTTPRequestHandler): 29 | server: FroniusServer 30 | 31 | def translate_path(self, path): 32 | """Translate a /-separated PATH to the local filename syntax. 33 | 34 | Components that mean special things to the local file system 35 | (e.g. drive or directory names) are ignored. (XXX They should 36 | probably be diagnosed.) 37 | 38 | only slightly changed method of the standard library 39 | """ 40 | # abandon query parameters 41 | # path = path.split('?',1)[0] -> Keep them for fronius as name of file 42 | path = path.split("#", 1)[0] 43 | # Don't forget explicit trailing slash when normalizing. Issue17324 44 | trailing_slash = path.rstrip().endswith("/") 45 | # Unquote first (standard behavior) 46 | try: 47 | path = urllib.parse.unquote(path, errors="surrogatepass") 48 | except UnicodeDecodeError: 49 | path = urllib.parse.unquote(path) 50 | # After unquoting, convert URL query parameters to Windows-safe filename format: 51 | # Split at ? and URL-encode the query string portion 52 | if "?" in path: 53 | base, query = path.split("?", 1) 54 | # URL encode the query string, keeping = safe for readability 55 | path = f"{base}___{query}" 56 | path = posixpath.normpath(path) 57 | words = path.split("/") 58 | words = filter(None, words) 59 | path = os.path.join( 60 | str(SERVER_DIR.absolute()), "v{}".format(self.server.api_version) 61 | ) 62 | for word in words: 63 | if os.path.dirname(word) or word in (os.curdir, os.pardir): 64 | # Ignore components that are not a simple file/directory name 65 | continue 66 | path = os.path.join(path, word) 67 | if trailing_slash: 68 | path += "/" 69 | return path 70 | 71 | def send_error(self, code, message=None, explain=None): 72 | """ 73 | Send blnet zugang verweigert page 74 | :param code: 75 | :param message: 76 | :param explain: 77 | :return: 78 | """ 79 | self.log_error("code %d, message %s", code, message) 80 | self.send_response(code, message) 81 | self.send_header("Connection", "close") 82 | 83 | # Message body is omitted for cases described in: 84 | # - RFC7230: 3.3. 1xx, 204(No Content), 304(Not Modified) 85 | # - RFC7231: 6.3.6. 205(Reset Content) 86 | body = None 87 | if code >= 200 and code not in ( 88 | HTTPStatus.NO_CONTENT, 89 | HTTPStatus.RESET_CONTENT, 90 | HTTPStatus.NOT_MODIFIED, 91 | ): 92 | # HTML encode to prevent Cross Site Scripting attacks 93 | # (see bug #1100201) 94 | # Specialized error method for fronius 95 | with ( 96 | SERVER_DIR.joinpath("v{}".format(self.server.api_version)) 97 | .joinpath(".error.html") 98 | .open("rb") 99 | ) as file: 100 | body = file.read() 101 | self.send_header("Content-Type", self.error_content_type) 102 | self.send_header("Content-Length", int(len(body))) 103 | self.end_headers() 104 | 105 | if self.command != "HEAD" and body: 106 | self.wfile.write(body) 107 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | 3 | .idea 4 | Pipfile.lock 5 | 6 | # Created by https://www.gitignore.io/api/python,pycharm,eclipse 7 | # Edit at https://www.gitignore.io/?templates=python,pycharm,eclipse 8 | 9 | ### Eclipse ### 10 | 11 | .metadata 12 | bin/ 13 | tmp/ 14 | *.tmp 15 | *.bak 16 | *.swp 17 | *~.nib 18 | local.properties 19 | .settings/ 20 | .loadpath 21 | .recommenders 22 | 23 | # External tool builders 24 | .externalToolBuilders/ 25 | 26 | # Locally stored "Eclipse launch configurations" 27 | *.launch 28 | 29 | # PyDev specific (Python IDE for Eclipse) 30 | *.pydevproject 31 | 32 | # CDT-specific (C/C++ Development Tooling) 33 | .cproject 34 | 35 | # CDT- autotools 36 | .autotools 37 | 38 | # Java annotation processor (APT) 39 | .factorypath 40 | 41 | # PDT-specific (PHP Development Tools) 42 | .buildpath 43 | 44 | # sbteclipse plugin 45 | .target 46 | 47 | # Tern plugin 48 | .tern-project 49 | 50 | # TeXlipse plugin 51 | .texlipse 52 | 53 | # STS (Spring Tool Suite) 54 | .springBeans 55 | 56 | # Code Recommenders 57 | .recommenders/ 58 | 59 | # Annotation Processing 60 | .apt_generated/ 61 | 62 | # Scala IDE specific (Scala & Java development for Eclipse) 63 | .cache-main 64 | .scala_dependencies 65 | .worksheet 66 | 67 | ### Eclipse Patch ### 68 | # Eclipse Core 69 | .project 70 | 71 | # JDT-specific (Eclipse Java Development Tools) 72 | .classpath 73 | 74 | # Annotation Processing 75 | .apt_generated 76 | 77 | .sts4-cache/ 78 | 79 | ### PyCharm ### 80 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and WebStorm 81 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 82 | 83 | # User-specific stuff 84 | .idea/**/workspace.xml 85 | .idea/**/tasks.xml 86 | .idea/**/usage.statistics.xml 87 | .idea/**/dictionaries 88 | .idea/**/shelf 89 | 90 | # Generated files 91 | .idea/**/contentModel.xml 92 | 93 | # Sensitive or high-churn files 94 | .idea/**/dataSources/ 95 | .idea/**/dataSources.ids 96 | .idea/**/dataSources.local.xml 97 | .idea/**/sqlDataSources.xml 98 | .idea/**/dynamic.xml 99 | .idea/**/uiDesigner.xml 100 | .idea/**/dbnavigator.xml 101 | 102 | # Gradle 103 | .idea/**/gradle.xml 104 | .idea/**/libraries 105 | 106 | # Gradle and Maven with auto-import 107 | # When using Gradle or Maven with auto-import, you should exclude module files, 108 | # since they will be recreated, and may cause churn. Uncomment if using 109 | # auto-import. 110 | # .idea/modules.xml 111 | # .idea/*.iml 112 | # .idea/modules 113 | 114 | # CMake 115 | cmake-build-*/ 116 | 117 | # Mongo Explorer plugin 118 | .idea/**/mongoSettings.xml 119 | 120 | # File-based project format 121 | *.iws 122 | 123 | # IntelliJ 124 | out/ 125 | 126 | # mpeltonen/sbt-idea plugin 127 | .idea_modules/ 128 | 129 | # JIRA plugin 130 | atlassian-ide-plugin.xml 131 | 132 | # Cursive Clojure plugin 133 | .idea/replstate.xml 134 | 135 | # Crashlytics plugin (for Android Studio and IntelliJ) 136 | com_crashlytics_export_strings.xml 137 | crashlytics.properties 138 | crashlytics-build.properties 139 | fabric.properties 140 | 141 | # Editor-based Rest Client 142 | .idea/httpRequests 143 | 144 | # Android studio 3.1+ serialized cache file 145 | .idea/caches/build_file_checksums.ser 146 | 147 | ### PyCharm Patch ### 148 | # Comment Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-215987721 149 | 150 | # *.iml 151 | # modules.xml 152 | # .idea/misc.xml 153 | # *.ipr 154 | 155 | # Sonarlint plugin 156 | .idea/sonarlint 157 | 158 | ### Python ### 159 | # Byte-compiled / optimized / DLL files 160 | __pycache__/ 161 | *.py[cod] 162 | *$py.class 163 | 164 | # C extensions 165 | *.so 166 | 167 | # Distribution / packaging 168 | .Python 169 | build/ 170 | develop-eggs/ 171 | dist/ 172 | downloads/ 173 | eggs/ 174 | .eggs/ 175 | lib/ 176 | lib64/ 177 | parts/ 178 | sdist/ 179 | var/ 180 | wheels/ 181 | share/python-wheels/ 182 | *.egg-info/ 183 | .installed.cfg 184 | *.egg 185 | MANIFEST 186 | 187 | # PyInstaller 188 | # Usually these files are written by a python script from a template 189 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 190 | *.manifest 191 | *.spec 192 | 193 | # Installer logs 194 | pip-log.txt 195 | pip-delete-this-directory.txt 196 | 197 | # Unit test / coverage reports 198 | htmlcov/ 199 | .tox/ 200 | .nox/ 201 | .coverage 202 | .coverage.* 203 | .cache 204 | nosetests.xml 205 | coverage.xml 206 | *.cover 207 | .hypothesis/ 208 | .pytest_cache/ 209 | 210 | # Translations 211 | *.mo 212 | *.pot 213 | 214 | # Django stuff: 215 | *.log 216 | local_settings.py 217 | db.sqlite3 218 | 219 | # Flask stuff: 220 | instance/ 221 | .webassets-cache 222 | 223 | # Scrapy stuff: 224 | .scrapy 225 | 226 | # Sphinx documentation 227 | docs/_build/ 228 | 229 | # PyBuilder 230 | target/ 231 | 232 | # Jupyter Notebook 233 | .ipynb_checkpoints 234 | 235 | # IPython 236 | profile_default/ 237 | ipython_config.py 238 | 239 | # pyenv 240 | .python-version 241 | 242 | # celery beat schedule file 243 | celerybeat-schedule 244 | 245 | # SageMath parsed files 246 | *.sage.py 247 | 248 | # Environments 249 | .env 250 | .venv 251 | env/ 252 | venv/ 253 | ENV/ 254 | env.bak/ 255 | venv.bak/ 256 | 257 | # Spyder project settings 258 | .spyderproject 259 | .spyproject 260 | 261 | # Rope project settings 262 | .ropeproject 263 | 264 | # mkdocs documentation 265 | /site 266 | 267 | # mypy 268 | .mypy_cache/ 269 | .dmypy.json 270 | dmypy.json 271 | 272 | # Pyre type checker 273 | .pyre/ 274 | 275 | ### Python Patch ### 276 | .venv/ 277 | 278 | # End of https://www.gitignore.io/api/python,pycharm,eclipse 279 | 280 | # eclipse / PyDev 281 | .project 282 | .pydevproject 283 | .settings/ 284 | 285 | -------------------------------------------------------------------------------- /tests/web_raw/v1/output: -------------------------------------------------------------------------------- 1 | DEBUG:asyncio:Using selector: EpollSelector 2 | DEBUG:urllib3.connectionpool:Starting new HTTP connection (1): 192.168.178.71:80 3 | DEBUG:urllib3.connectionpool:http://192.168.178.71:80 "GET /solar_api/v1/GetPowerFlowRealtimeData.fcgi HTTP/1.1" 200 None 4 | DEBUG:pyfronius:{'Head': {'RequestArguments': {}, 'Status': {'Code': 0, 'Reason': '', 'UserMessage': ''}, 'Timestamp': '2019-01-10T23:33:12+01:00'}, 'Body': {'Data': {'Site': {'Mode': 'vague-meter', 'P_Grid': 367.722145, 'P_Load': -367.722145, 'P_Akku': None, 'P_PV': None, 'E_Day': 0, 'E_Year': 12400.100586, 'E_Total': 26213502, 'Meter_Location': 'load'}, 'Inverters': {'1': {'DT': 123, 'P': None}}}}} 5 | DEBUG:pyfronius:{'timestamp': {'value': '2019-01-10T23:33:12+01:00'}, 'status': {'Code': 0, 'Reason': '', 'UserMessage': ''}, 'energy_day': {'value': 0, 'unit': 'Wh'}, 'energy_total': {'value': 26213502, 'unit': 'Wh'}, 'energy_year': {'value': 12400.100586, 'unit': 'Wh'}, 'meter_location': {'value': 'load'}, 'meter_mode': {'value': 'vague-meter'}, 'power_battery': {'value': None, 'unit': 'W'}, 'power_grid': {'value': 367.722145, 'unit': 'W'}, 'power_load': {'value': -367.722145, 'unit': 'W'}, 'power_photovoltaics': {'value': None, 'unit': 'W'}} 6 | DEBUG:urllib3.connectionpool:Starting new HTTP connection (1): 192.168.178.71:80 7 | DEBUG:urllib3.connectionpool:http://192.168.178.71:80 "GET /solar_api/v1/GetMeterRealtimeData.cgi?Scope=System HTTP/1.1" 200 None 8 | DEBUG:pyfronius:{'Head': {'RequestArguments': {'DeviceClass': 'Meter', 'Scope': 'System'}, 'Status': {'Code': 0, 'Reason': '', 'UserMessage': ''}, 'Timestamp': '2019-01-10T23:33:13+01:00'}, 'Body': {'Data': {'0': {'Details': {'Serial': '', 'Model': '', 'Manufacturer': 'Fronius'}, 'TimeStamp': 1547159592, 'Enable': 1, 'Visible': 1, 'PowerReal_P_Sum': -367.722145, 'Meter_Location_Current': 1, 'EnergyReal_WAC_Minus_Relative': 17}}}} 9 | DEBUG:pyfronius:{'timestamp': {'value': '2019-01-10T23:33:13+01:00'}, 'status': {'Code': 0, 'Reason': '', 'UserMessage': ''}, 'meters': {'0': {'power_real': {'value': -367.722145, 'unit': 'W'}, 'meter_location': {'value': 1}, 'enable': {'value': 1}, 'visible': {'value': 1}, 'manufacturer': {'value': 'Fronius'}, 'model': {'value': ''}, 'serial': {'value': ''}}}} 10 | DEBUG:urllib3.connectionpool:Starting new HTTP connection (1): 192.168.178.71:80 11 | DEBUG:urllib3.connectionpool:http://192.168.178.71:80 "GET /solar_api/v1/GetMeterRealtimeData.cgi?Scope=Device&DeviceId=0 HTTP/1.1" 200 None 12 | DEBUG:pyfronius:{'Head': {'RequestArguments': {'DeviceClass': 'Meter', 'DeviceId': '0', 'Scope': 'Device'}, 'Status': {'Code': 0, 'Reason': '', 'UserMessage': ''}, 'Timestamp': '2019-01-10T23:33:14+01:00'}, 'Body': {'Data': {'Details': {'Serial': '', 'Model': '', 'Manufacturer': 'Fronius'}, 'TimeStamp': 1547159593, 'Enable': 1, 'Visible': 1, 'PowerReal_P_Sum': -367.722145, 'Meter_Location_Current': 1, 'EnergyReal_WAC_Minus_Relative': 17}}} 13 | DEBUG:pyfronius:{'timestamp': {'value': '2019-01-10T23:33:14+01:00'}, 'status': {'Code': 0, 'Reason': '', 'UserMessage': ''}, 'power_real': {'value': -367.722145, 'unit': 'W'}, 'meter_location': {'value': 1}, 'enable': {'value': 1}, 'visible': {'value': 1}, 'manufacturer': {'value': 'Fronius'}, 'model': {'value': ''}, 'serial': {'value': ''}} 14 | DEBUG:urllib3.connectionpool:Starting new HTTP connection (1): 192.168.178.71:80 15 | DEBUG:urllib3.connectionpool:http://192.168.178.71:80 "GET /solar_api/v1/GetStorageRealtimeData.cgi?Scope=Device&DeviceId=0 HTTP/1.1" 200 None 16 | DEBUG:pyfronius:{'Head': {'RequestArguments': {'DeviceClass': 'Storage', 'Scope': 'Device'}, 'Status': {'Code': 255, 'Reason': 'Storages are not supported', 'UserMessage': ''}, 'Timestamp': '2019-01-10T23:33:14+01:00'}} 17 | DEBUG:urllib3.connectionpool:Starting new HTTP connection (1): 192.168.178.71:80 18 | DEBUG:urllib3.connectionpool:http://192.168.178.71:80 "GET /solar_api/v1/GetInverterRealtimeData.cgi?Scope=Device&DeviceId=1&DataCollection=CommonInverterData HTTP/1.1" 200 None 19 | DEBUG:pyfronius:{'Head': {'RequestArguments': {'DataCollection': 'CommonInverterData', 'DeviceClass': 'Inverter', 'DeviceId': '1', 'Scope': 'Device'}, 'Status': {'Code': 0, 'Reason': '', 'UserMessage': ''}, 'Timestamp': '2019-01-10T23:33:15+01:00'}, 'Body': {'Data': {'DAY_ENERGY': {'Value': 0, 'Unit': 'Wh'}, 'TOTAL_ENERGY': {'Value': 26213502, 'Unit': 'Wh'}, 'YEAR_ENERGY': {'Value': 12400.1, 'Unit': 'Wh'}, 'DeviceStatus': {'StatusCode': 3, 'MgmtTimerRemainingTime': -1, 'ErrorCode': 523, 'LEDColor': 1, 'LEDState': 0, 'StateToReset': True}}}} 20 | DEBUG:pyfronius:{'timestamp': {'value': '2019-01-10T23:33:15+01:00'}, 'status': {'Code': 0, 'Reason': '', 'UserMessage': ''}, 'energy_day': {'value': 0, 'unit': 'Wh'}, 'energy_total': {'value': 26213502, 'unit': 'Wh'}, 'energy_year': {'value': 12400.1, 'unit': 'Wh'}} 21 | DEBUG:urllib3.connectionpool:Starting new HTTP connection (1): 192.168.178.71:80 22 | DEBUG:urllib3.connectionpool:http://192.168.178.71:80 "GET /solar_api/v1/GetInverterRealtimeData.cgi?Scope=System HTTP/1.1" 200 None 23 | DEBUG:pyfronius:{'Head': {'RequestArguments': {'DataCollection': '', 'Scope': 'System'}, 'Status': {'Code': 0, 'Reason': '', 'UserMessage': ''}, 'Timestamp': '2019-01-10T23:33:16+01:00'}, 'Body': {'Data': {'PAC': {'Unit': 'W', 'Values': {'1': 0}}, 'DAY_ENERGY': {'Unit': 'Wh', 'Values': {'1': 0}}, 'YEAR_ENERGY': {'Unit': 'Wh', 'Values': {'1': 12400}}, 'TOTAL_ENERGY': {'Unit': 'Wh', 'Values': {'1': 26213502}}}}} 24 | DEBUG:pyfronius:{'timestamp': {'value': '2019-01-10T23:33:16+01:00'}, 'status': {'Code': 0, 'Reason': '', 'UserMessage': ''}, 'energy_day': {'value': 0, 'unit': 'Wh'}, 'energy_total': {'value': 26213502, 'unit': 'Wh'}, 'energy_year': {'value': 12400, 'unit': 'Wh'}, 'power_ac': {'value': 0, 'unit': 'W'}, 'inverters': {'1': {'energy_day': {'value': 0, 'unit': 'Wh'}, 'energy_total': {'value': 26213502, 'unit': 'Wh'}, 'energy_year': {'value': 12400, 'unit': 'Wh'}, 'power_ac': {'value': 0, 'unit': 'Wh'}}}} 25 | -------------------------------------------------------------------------------- /tests/test_web_v0.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | # general requirements 5 | import unittest 6 | 7 | from .util import AsyncTestCaseSetup, _get_unused_port, ADDRESS 8 | from .test_structure.server_control import Server 9 | from .test_structure.fronius_mock_server import FroniusRequestHandler, FroniusServer 10 | from http.server import SimpleHTTPRequestHandler 11 | 12 | # For the server in this case 13 | import time 14 | 15 | # For the tests 16 | import aiohttp 17 | import pyfronius 18 | from tests.web_raw.v0.web_state import ( 19 | GET_INVERTER_REALTIME_DATA_SCOPE_DEVICE, 20 | GET_INVERTER_REALTIME_DATA_SYSTEM, 21 | GET_LOGGER_INFO, 22 | GET_INVERTER_INFO, 23 | ) 24 | 25 | 26 | class NoFroniusWebTest(AsyncTestCaseSetup): 27 | server = None 28 | api_version = pyfronius.API_VERSION.V0 29 | server_control = None 30 | port = 0 31 | url = f"http://{ADDRESS}:80" 32 | session = None 33 | fronius = None 34 | 35 | async def setUp(self): 36 | # Pick an unused port to ensure the connection attempt fails deterministically 37 | self.port = _get_unused_port() 38 | self.url = "http://{}:{}".format(ADDRESS, self.port) 39 | 40 | async def test_no_server(self): 41 | # set up a fronius client and aiohttp session 42 | self.session = aiohttp.ClientSession() 43 | self.fronius = pyfronius.Fronius(self.session, self.url, self.api_version) 44 | try: 45 | await self.fronius.current_system_inverter_data() 46 | self.fail("No Exception for failed connection to fronius") 47 | except ConnectionError: 48 | await self.session.close() 49 | 50 | async def test_wrong_server(self): 51 | # This request handler ignores queries and should return the error page 52 | handler = SimpleHTTPRequestHandler 53 | 54 | max_retries = 10 55 | r = 0 56 | while not self.server: 57 | try: 58 | # Connect to any open port 59 | self.server = FroniusServer( 60 | (ADDRESS, 0), handler, self.api_version.value 61 | ) 62 | except OSError: 63 | if r < max_retries: 64 | r += 1 65 | else: 66 | raise 67 | time.sleep(1) 68 | 69 | self.server_control = Server(self.server) 70 | self.port = self.server_control.get_port() 71 | self.url = "http://{}:{}".format(ADDRESS, self.port) 72 | # Start test server before running any tests 73 | self.server_control.start_server() 74 | # set up a fronius client and aiohttp session 75 | self.session = aiohttp.ClientSession() 76 | self.fronius = pyfronius.Fronius(self.session, self.url, self.api_version) 77 | try: 78 | await self.fronius.current_system_inverter_data() 79 | self.fail("No Exception for wrong reply by host") 80 | except pyfronius.NotSupportedError: 81 | pass 82 | finally: 83 | await self.session.close() 84 | 85 | 86 | class FroniusWebDetectVersionV0(AsyncTestCaseSetup): 87 | server = None 88 | api_version = pyfronius.API_VERSION.V0 89 | server_control = None 90 | port = 0 91 | url = "http://localhost:80" 92 | session = None 93 | fronius = None 94 | 95 | async def setUp(self): 96 | # Create an arbitrary subclass of TCP Server as the server to be 97 | # started 98 | # Here, it is an Simple HTTP file serving server 99 | handler = FroniusRequestHandler 100 | 101 | max_retries = 10 102 | r = 0 103 | while not self.server: 104 | try: 105 | # Connect to any open port 106 | self.server = FroniusServer( 107 | (ADDRESS, 0), handler, self.api_version.value 108 | ) 109 | except OSError: 110 | if r < max_retries: 111 | r += 1 112 | else: 113 | raise 114 | time.sleep(1) 115 | 116 | self.server_control = Server(self.server) 117 | self.port = self.server_control.get_port() 118 | self.url = "http://{}:{}".format(ADDRESS, self.port) 119 | # Start test server before running any tests 120 | self.server_control.start_server() 121 | # set up a fronius client and aiohttp session 122 | self.session = aiohttp.ClientSession() 123 | self.fronius = pyfronius.Fronius(self.session, self.url) # auto api_version 124 | 125 | async def test_fronius_get_correct_api_version(self): 126 | # fetch any data to check if the correct api_version is retreived 127 | res = await self.fronius.current_inverter_data() 128 | self.assertEqual(res, GET_INVERTER_REALTIME_DATA_SCOPE_DEVICE) 129 | self.assertEqual(self.fronius.api_version, self.api_version) 130 | 131 | 132 | class FroniusWebTestV0(AsyncTestCaseSetup): 133 | server = None 134 | api_version = pyfronius.API_VERSION.V0 135 | server_control = None 136 | port = 0 137 | url = "http://localhost:80" 138 | session = None 139 | fronius = None 140 | 141 | async def setUp(self): 142 | # Create an arbitrary subclass of TCP Server as the server to be 143 | # started 144 | # Here, it is an Simple HTTP file serving server 145 | handler = FroniusRequestHandler 146 | 147 | max_retries = 10 148 | r = 0 149 | while not self.server: 150 | try: 151 | # Connect to any open port 152 | self.server = FroniusServer( 153 | (ADDRESS, 0), handler, self.api_version.value 154 | ) 155 | except OSError: 156 | if r < max_retries: 157 | r += 1 158 | else: 159 | raise 160 | time.sleep(1) 161 | 162 | self.server_control = Server(self.server) 163 | self.port = self.server_control.get_port() 164 | self.url = "http://{}:{}".format(ADDRESS, self.port) 165 | # Start test server before running any tests 166 | self.server_control.start_server() 167 | # set up a fronius client and aiohttp session 168 | self.session = aiohttp.ClientSession() 169 | self.fronius = pyfronius.Fronius(self.session, self.url, self.api_version) 170 | 171 | async def test_fronius_get_inverter_realtime_data_device(self): 172 | res = await self.fronius.current_inverter_data() 173 | self.assertDictEqual(res, GET_INVERTER_REALTIME_DATA_SCOPE_DEVICE) 174 | 175 | async def test_fronius_get_inverter_realtime_data_system(self): 176 | res = await self.fronius.current_system_inverter_data() 177 | self.assertDictEqual(res, GET_INVERTER_REALTIME_DATA_SYSTEM) 178 | 179 | async def test_fronius_get_meter_realtime_data_system(self): 180 | with self.assertRaises(pyfronius.NotSupportedError): 181 | await self.fronius.current_system_meter_data() 182 | 183 | async def test_fronius_get_meter_realtime_data_device(self): 184 | with self.assertRaises(pyfronius.NotSupportedError): 185 | await self.fronius.current_meter_data() 186 | 187 | async def test_fronius_get_power_flow_realtime_data(self): 188 | with self.assertRaises(pyfronius.NotSupportedError): 189 | await self.fronius.current_power_flow() 190 | 191 | async def test_fronius_get_led_info_data(self): 192 | with self.assertRaises(pyfronius.NotSupportedError): 193 | await self.fronius.current_led_data() 194 | 195 | async def test_fronius_get_active_device_info(self): 196 | with self.assertRaises(pyfronius.NotSupportedError): 197 | await self.fronius.current_active_device_info() 198 | 199 | async def test_fronius_get_logger_info(self): 200 | res = await self.fronius.current_logger_info() 201 | self.assertDictEqual(res, GET_LOGGER_INFO) 202 | 203 | async def test_fronius_get_inverter_info(self): 204 | res = await self.fronius.inverter_info() 205 | self.assertDictEqual(res, GET_INVERTER_INFO) 206 | 207 | async def test_fronius_get_no_data(self): 208 | # Storage data for device 0 is not provided ATM 209 | # TODO someone add some storage data for a device 1? 210 | with self.assertRaises(pyfronius.NotSupportedError): 211 | await self.fronius.current_storage_data() 212 | 213 | async def tearDown(self): 214 | await self.session.close() 215 | self.server_control.stop_server() 216 | pass 217 | 218 | 219 | if __name__ == "__main__": 220 | unittest.main() 221 | -------------------------------------------------------------------------------- /tests/test_web_v1.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | # general requirements 5 | import unittest 6 | 7 | 8 | from .util import AsyncTestCaseSetup, _get_unused_port, ADDRESS 9 | from .test_structure.server_control import Server 10 | from .test_structure.fronius_mock_server import FroniusRequestHandler, FroniusServer 11 | from http.server import SimpleHTTPRequestHandler 12 | 13 | # For the server in this case 14 | import time 15 | 16 | # For the tests 17 | import aiohttp 18 | import pyfronius 19 | from tests.web_raw.v1.web_state import ( 20 | GET_ACTIVE_DEVICE_INFO, 21 | GET_INVERTER_REALTIME_DATA_SYSTEM, 22 | GET_METER_REALTIME_DATA_SCOPE_DEVICE, 23 | GET_STORAGE_REALTIME_DATA_SCOPE_DEVICE, 24 | GET_INVERTER_REALTIME_DATA_SCOPE_DEVICE, 25 | GET_INVERTER_REALTIME_3P_DATA_SCOPE_DEVICE, 26 | GET_STORAGE_REALTIME_DATA_SYSTEM, 27 | GET_METER_REALTIME_DATA_SYSTEM, 28 | GET_LOGGER_LED_INFO_STATE, 29 | GET_OHMPILOT_REALTIME_DATA_SYSTEM, 30 | GET_POWER_FLOW_REALTIME_DATA, 31 | GET_LOGGER_INFO, 32 | GET_INVERTER_INFO, 33 | ) 34 | 35 | 36 | class NoFroniusWebTest(AsyncTestCaseSetup): 37 | server = None 38 | api_version = pyfronius.API_VERSION.V1 39 | server_control = None 40 | port = 0 41 | url = f"http://{ADDRESS}:80" 42 | session = None 43 | fronius = None 44 | 45 | async def setUp(self): 46 | # Pick an unused port to ensure the connection attempt fails deterministically 47 | self.port = _get_unused_port() 48 | self.url = "http://{}:{}".format(ADDRESS, self.port) 49 | 50 | async def test_no_server(self): 51 | # set up a fronius client and aiohttp session 52 | self.session = aiohttp.ClientSession() 53 | self.fronius = pyfronius.Fronius(self.session, self.url, self.api_version) 54 | try: 55 | await self.fronius.current_system_meter_data() 56 | self.fail("No Exception for failed connection to fronius") 57 | except ConnectionError: 58 | pass 59 | finally: 60 | await self.session.close() 61 | 62 | async def test_wrong_server(self): 63 | # This request handler ignores queries and should return the error page 64 | handler = SimpleHTTPRequestHandler 65 | 66 | max_retries = 10 67 | r = 0 68 | while not self.server: 69 | try: 70 | # Connect to any open port 71 | self.server = FroniusServer( 72 | (ADDRESS, 0), handler, self.api_version.value 73 | ) 74 | except OSError: 75 | if r < max_retries: 76 | r += 1 77 | else: 78 | raise 79 | time.sleep(1) 80 | 81 | self.server_control = Server(self.server) 82 | self.port = self.server_control.get_port() 83 | self.url = "http://{}:{}".format(ADDRESS, self.port) 84 | # Start test server before running any tests 85 | self.server_control.start_server() 86 | # set up a fronius client and aiohttp session 87 | self.session = aiohttp.ClientSession() 88 | self.fronius = pyfronius.Fronius(self.session, self.url) 89 | try: 90 | await self.fronius.current_system_inverter_data() 91 | self.fail("No Exception for wrong reply by host") 92 | except pyfronius.NotSupportedError: 93 | pass 94 | finally: 95 | await self.session.close() 96 | 97 | 98 | class FroniusWebDetectVersionV1(AsyncTestCaseSetup): 99 | server = None 100 | api_version = pyfronius.API_VERSION.V1 101 | server_control = None 102 | port = 0 103 | url = "http://localhost:80" 104 | session = None 105 | fronius = None 106 | 107 | async def setUp(self): 108 | # Create an arbitrary subclass of TCP Server as the server to be 109 | # started 110 | # Here, it is an Simple HTTP file serving server 111 | handler = FroniusRequestHandler 112 | 113 | max_retries = 10 114 | r = 0 115 | while not self.server: 116 | try: 117 | # Connect to any open port 118 | self.server = FroniusServer( 119 | (ADDRESS, 0), handler, self.api_version.value 120 | ) 121 | except OSError: 122 | if r < max_retries: 123 | r += 1 124 | else: 125 | raise 126 | time.sleep(1) 127 | 128 | self.server_control = Server(self.server) 129 | self.port = self.server_control.get_port() 130 | self.url = "http://{}:{}".format(ADDRESS, self.port) 131 | # Start test server before running any tests 132 | self.server_control.start_server() 133 | # set up a fronius client and aiohttp session 134 | self.session = aiohttp.ClientSession() 135 | self.fronius = pyfronius.Fronius(self.session, self.url) # auto api_version 136 | 137 | async def test_fronius_get_correct_api_version(self): 138 | # fetch any data to check if the correct api_version is retreived 139 | res = await self.fronius.current_inverter_data() 140 | self.assertDictEqual(res, GET_INVERTER_REALTIME_DATA_SCOPE_DEVICE) 141 | self.assertEqual(self.fronius.api_version, self.api_version) 142 | 143 | 144 | class FroniusWebTestV1(AsyncTestCaseSetup): 145 | server = None 146 | api_version = pyfronius.API_VERSION.V1 147 | server_control = None 148 | port = 0 149 | url = "http://localhost:80" 150 | session = None 151 | fronius = None 152 | 153 | async def setUp(self): 154 | # Create an arbitrary subclass of TCP Server as the server to be 155 | # started 156 | # Here, it is an Simple HTTP file serving server 157 | handler = FroniusRequestHandler 158 | 159 | max_retries = 10 160 | r = 0 161 | while not self.server: 162 | try: 163 | # Connect to any open port 164 | self.server = FroniusServer( 165 | (ADDRESS, 0), handler, self.api_version.value 166 | ) 167 | except OSError: 168 | if r < max_retries: 169 | r += 1 170 | else: 171 | raise 172 | time.sleep(1) 173 | 174 | self.server_control = Server(self.server) 175 | self.port = self.server_control.get_port() 176 | self.url = "http://{}:{}".format(ADDRESS, self.port) 177 | # Start test server before running any tests 178 | self.server_control.start_server() 179 | # set up a fronius client and aiohttp session 180 | self.session = aiohttp.ClientSession() 181 | self.fronius = pyfronius.Fronius(self.session, self.url, self.api_version) 182 | 183 | async def test_fronius_get_meter_realtime_data_system(self): 184 | res = await self.fronius.current_system_meter_data() 185 | self.assertDictEqual(res, GET_METER_REALTIME_DATA_SYSTEM) 186 | 187 | async def test_fronius_get_meter_realtime_data_device(self): 188 | res = await self.fronius.current_meter_data() 189 | self.assertDictEqual(res, GET_METER_REALTIME_DATA_SCOPE_DEVICE) 190 | 191 | async def test_fronius_get_power_flow_realtime_data(self): 192 | res = await self.fronius.current_power_flow() 193 | self.assertDictEqual(res, GET_POWER_FLOW_REALTIME_DATA) 194 | 195 | async def test_fronius_get_inverter_realtime_data_device(self): 196 | res = await self.fronius.current_inverter_data() 197 | self.assertDictEqual(res, GET_INVERTER_REALTIME_DATA_SCOPE_DEVICE) 198 | 199 | async def test_fronius_get_inverter_realtime_3p_data_device(self): 200 | res = await self.fronius.current_inverter_3p_data() 201 | self.assertDictEqual(res, GET_INVERTER_REALTIME_3P_DATA_SCOPE_DEVICE) 202 | 203 | async def test_fronius_get_inverter_realtime_data_system(self): 204 | res = await self.fronius.current_system_inverter_data() 205 | self.assertDictEqual(res, GET_INVERTER_REALTIME_DATA_SYSTEM) 206 | 207 | async def test_fronius_get_ohmpilot_realtime_data_system(self): 208 | res = await self.fronius.current_system_ohmpilot_data() 209 | self.assertDictEqual(res, GET_OHMPILOT_REALTIME_DATA_SYSTEM) 210 | 211 | async def test_fronius_get_led_info_data(self): 212 | res = await self.fronius.current_led_data() 213 | self.assertDictEqual(res, GET_LOGGER_LED_INFO_STATE) 214 | 215 | async def test_fronius_get_active_device_info(self): 216 | res = await self.fronius.current_active_device_info() 217 | self.assertDictEqual(res, GET_ACTIVE_DEVICE_INFO) 218 | 219 | async def test_fronius_get_logger_info(self): 220 | res = await self.fronius.current_logger_info() 221 | self.assertDictEqual(res, GET_LOGGER_INFO) 222 | 223 | async def test_fronius_get_inverter_info(self): 224 | res = await self.fronius.inverter_info() 225 | self.assertDictEqual(res, GET_INVERTER_INFO) 226 | 227 | async def test_fronius_get_storage_realtime_data_system(self): 228 | res = await self.fronius.current_system_storage_data() 229 | self.assertDictEqual(res, GET_STORAGE_REALTIME_DATA_SYSTEM) 230 | 231 | async def test_fronius_fetch(self): 232 | res = await self.fronius.fetch( 233 | active_device_info=True, 234 | inverter_info=True, 235 | logger_info=True, 236 | power_flow=True, 237 | system_meter=True, 238 | system_inverter=True, 239 | system_ohmpilot=True, 240 | system_storage=False, 241 | device_meter={0}, 242 | device_storage={0}, 243 | device_inverter={1}, 244 | ) 245 | self.assertEqual( 246 | res, 247 | [ 248 | GET_ACTIVE_DEVICE_INFO, 249 | GET_INVERTER_INFO, 250 | GET_LOGGER_INFO, 251 | GET_POWER_FLOW_REALTIME_DATA, 252 | GET_METER_REALTIME_DATA_SYSTEM, 253 | GET_INVERTER_REALTIME_DATA_SYSTEM, 254 | GET_OHMPILOT_REALTIME_DATA_SYSTEM, 255 | GET_METER_REALTIME_DATA_SCOPE_DEVICE, 256 | GET_STORAGE_REALTIME_DATA_SCOPE_DEVICE, 257 | GET_INVERTER_REALTIME_DATA_SCOPE_DEVICE, 258 | GET_INVERTER_REALTIME_3P_DATA_SCOPE_DEVICE, 259 | ], 260 | ) 261 | 262 | async def tearDown(self): 263 | await self.session.close() 264 | self.server_control.stop_server() 265 | pass 266 | 267 | 268 | if __name__ == "__main__": 269 | unittest.main() 270 | -------------------------------------------------------------------------------- /tests/web_raw/v1/web_state.py: -------------------------------------------------------------------------------- 1 | GET_POWER_FLOW_REALTIME_DATA = { 2 | "timestamp": {"value": "2019-01-10T23:33:12+01:00"}, 3 | "status": {"Code": 0, "Reason": "", "UserMessage": ""}, 4 | "energy_day": {"value": 0, "unit": "Wh"}, 5 | "energy_total": {"value": 26213502, "unit": "Wh"}, 6 | "energy_year": {"value": 12400.100586, "unit": "Wh"}, 7 | "meter_location": {"value": "load"}, 8 | "meter_mode": {"value": "vague-meter"}, 9 | "power_battery": {"value": None, "unit": "W"}, 10 | "power_grid": {"value": 367.722145, "unit": "W"}, 11 | "power_load": {"value": -367.722145, "unit": "W"}, 12 | "power_photovoltaics": {"value": None, "unit": "W"}, 13 | } 14 | 15 | GET_METER_REALTIME_DATA_SYSTEM = { 16 | "timestamp": {"value": "2019-01-10T23:33:13+01:00"}, 17 | "status": {"Code": 0, "Reason": "", "UserMessage": ""}, 18 | "meters": { 19 | "0": { 20 | "power_real": {"value": -367.722145, "unit": "W"}, 21 | "meter_location": {"value": 1}, 22 | "enable": {"value": 1}, 23 | "visible": {"value": 1}, 24 | "manufacturer": {"value": "Fronius"}, 25 | "model": {"value": ""}, 26 | "serial": {"value": ""}, 27 | } 28 | }, 29 | } 30 | 31 | GET_METER_REALTIME_DATA_SCOPE_DEVICE = { 32 | "timestamp": {"value": "2019-01-10T23:33:14+01:00"}, 33 | "status": {"Code": 0, "Reason": "", "UserMessage": ""}, 34 | "power_real": {"value": -367.722145, "unit": "W"}, 35 | "meter_location": {"value": 1}, 36 | "enable": {"value": 1}, 37 | "visible": {"value": 1}, 38 | "manufacturer": {"value": "Fronius"}, 39 | "model": {"value": ""}, 40 | "serial": {"value": ""}, 41 | } 42 | 43 | GET_INVERTER_REALTIME_DATA_SCOPE_DEVICE = { 44 | "timestamp": {"value": "2019-01-10T23:33:15+01:00"}, 45 | "status": {"Code": 0, "Reason": "", "UserMessage": ""}, 46 | "energy_day": {"value": 0, "unit": "Wh"}, 47 | "energy_total": {"value": 26213502, "unit": "Wh"}, 48 | "energy_year": {"value": 12400.1, "unit": "Wh"}, 49 | "status_code": {"value": 3}, 50 | "error_code": {"value": 523}, 51 | "led_state": {"value": 0}, 52 | "led_color": {"value": 1}, 53 | } 54 | 55 | GET_INVERTER_REALTIME_3P_DATA_SCOPE_DEVICE = { 56 | "timestamp": {"value": "2025-03-30T15:54:07+00:00"}, 57 | "status": {"Code": 0, "Reason": "", "UserMessage": ""}, 58 | "current_ac_phase_1": {"value": 0.92972046136856079, "unit": "A"}, 59 | "current_ac_phase_2": {"value": 0.92731237411499023, "unit": "A"}, 60 | "current_ac_phase_3": {"value": 0.93189901113510132, "unit": "A"}, 61 | "voltage_ac_phase_1": {"value": 231.87258911132812, "unit": "V"}, 62 | "voltage_ac_phase_2": {"value": 231.79225158691406, "unit": "V"}, 63 | "voltage_ac_phase_3": {"value": 230.89854431152344, "unit": "V"}, 64 | } 65 | 66 | GET_STORAGE_REALTIME_DATA_SCOPE_DEVICE_UNSUPPORTED = { 67 | "timestamp": {"value": "2019-01-10T23:33:14+01:00"}, 68 | "status": {"Code": 255, "Reason": "Storages are not supported", "UserMessage": ""}, 69 | } 70 | 71 | GET_STORAGE_REALTIME_DATA_SCOPE_DEVICE = { 72 | "timestamp": {"value": "2022-01-05T18:10:02+01:00"}, 73 | "status": {"Code": 0, "Reason": "", "UserMessage": ""}, 74 | "capacity_maximum": {"value": 11520, "unit": "Ah"}, 75 | "capacity_designed": {"value": 11520, "unit": "Ah"}, 76 | "voltage_dc": {"value": 0, "unit": "V"}, 77 | "state_of_charge": {"value": 8, "unit": "%"}, 78 | "temperature_cell": {"value": 19.70, "unit": "°C"}, 79 | "enable": {"value": 1}, 80 | "manufacturer": {"value": "BYD"}, 81 | "model": {"value": "BYD Battery-Box HV"}, 82 | "serial": {"value": "123456789-12345"}, 83 | "modules": {}, 84 | } 85 | 86 | GET_STORAGE_REALTIME_DATA_SYSTEM = { 87 | "timestamp": {"value": "2022-01-05T18:42:26+01:00"}, 88 | "status": {"Code": 0, "Reason": "", "UserMessage": ""}, 89 | "storages": { 90 | "0": { 91 | "capacity_maximum": {"value": 11520, "unit": "Ah"}, 92 | "capacity_designed": {"value": 11520, "unit": "Ah"}, 93 | "voltage_dc": {"value": 0, "unit": "V"}, 94 | "state_of_charge": {"value": 7.9, "unit": "%"}, 95 | "temperature_cell": {"value": 19.55, "unit": "°C"}, 96 | "enable": {"value": 1}, 97 | "manufacturer": {"value": "BYD"}, 98 | "model": {"value": "BYD Battery-Box HV"}, 99 | "serial": {"value": "123456789-12345"}, 100 | "modules": {}, 101 | } 102 | }, 103 | } 104 | 105 | GET_INVERTER_REALTIME_DATA_SYSTEM = { 106 | "timestamp": {"value": "2019-01-10T23:33:16+01:00"}, 107 | "status": {"Code": 0, "Reason": "", "UserMessage": ""}, 108 | "energy_day": {"value": 0, "unit": "Wh"}, 109 | "energy_total": {"value": 26213502, "unit": "Wh"}, 110 | "energy_year": {"value": 12400, "unit": "Wh"}, 111 | "power_ac": {"value": 0, "unit": "W"}, 112 | "inverters": { 113 | "1": { 114 | "energy_day": {"value": 0, "unit": "Wh"}, 115 | "energy_total": {"value": 26213502, "unit": "Wh"}, 116 | "energy_year": {"value": 12400, "unit": "Wh"}, 117 | "power_ac": {"value": 0, "unit": "W"}, 118 | } 119 | }, 120 | } 121 | 122 | GET_OHMPILOT_REALTIME_DATA_SYSTEM = { 123 | "timestamp": {"value": "2019-06-24T10:10:44+02:00"}, 124 | "status": {"Code": 0, "Reason": "", "UserMessage": ""}, 125 | "ohmpilots": { 126 | "0": { 127 | "error_code": {"value": 926}, 128 | "state_code": {"value": 0}, 129 | "state_message": {"value": "Up and running"}, 130 | "hardware": {"value": "3"}, 131 | "manufacturer": {"value": "Fronius"}, 132 | "model": {"value": "Ohmpilot"}, 133 | "serial": {"value": "28136344"}, 134 | "software": {"value": "1.0.19-1"}, 135 | "energy_real_ac_consumed": {"value": 2964307, "unit": "Wh"}, 136 | "power_real_ac": {"value": 0, "unit": "W"}, 137 | "temperature_channel_1": {"value": 23.9, "unit": "°C"}, 138 | } 139 | }, 140 | } 141 | 142 | GET_LOGGER_LED_INFO_STATE = { 143 | "timestamp": {"value": "2019-06-23T23:50:16+02:00"}, 144 | "status": {"Code": 0, "Reason": "", "UserMessage": ""}, 145 | "power_led": {"color": "green", "state": "on"}, 146 | "solar_net_led": {"color": "green", "state": "on"}, 147 | "solar_web_led": {"color": "none", "state": "off"}, 148 | "wlan_led": {"color": "green", "state": "on"}, 149 | } 150 | 151 | GET_ACTIVE_DEVICE_INFO = { 152 | "timestamp": {"value": "2021-08-17T14:12:17+02:00"}, 153 | "status": {"Code": 0, "Reason": "", "UserMessage": ""}, 154 | "inverters": [{"device_id": "1", "device_type": 122, "serial_number": "30412345"}], 155 | "meters": [{"device_id": "0", "serial_number": "18412345"}], 156 | "ohmpilots": [], 157 | "sensor_cards": [], 158 | "storages": [], 159 | "string_controls": [], 160 | } 161 | 162 | GET_LOGGER_INFO = { 163 | "timestamp": {"value": "2021-08-17T14:36:40+02:00"}, 164 | "status": {"Code": 0, "Reason": "", "UserMessage": ""}, 165 | "co2_factor": {"value": 0.5299999713897705, "unit": "kg/kWh"}, 166 | "cash_factor": {"value": 0.07599999755620956, "unit": "EUR/kWh"}, 167 | "delivery_factor": {"value": 0.15000000596046448, "unit": "EUR/kWh"}, 168 | "hardware_version": {"value": "2.4E"}, 169 | "software_version": {"value": "3.18.7-1"}, 170 | "hardware_platform": {"value": "wilma"}, 171 | "product_type": {"value": "fronius-datamanager-card"}, 172 | "time_zone_location": {"value": "Vienna"}, 173 | "time_zone": {"value": "CEST"}, 174 | "utc_offset": {"value": 7200}, 175 | "unique_identifier": {"value": "240.123456"}, 176 | } 177 | 178 | GET_INVERTER_INFO = { 179 | "timestamp": {"value": "2019-06-12T15:31:02+02:00"}, 180 | "status": {"Code": 0, "Reason": "", "UserMessage": ""}, 181 | "inverters": [ 182 | { 183 | "device_id": {"value": "1"}, 184 | "custom_name": {"value": "Primo 8.2-1 ("}, 185 | "device_type": { 186 | "value": 102, 187 | "manufacturer": "Fronius", 188 | "model": "Primo 8.2-1", 189 | }, 190 | "error_code": {"value": 0}, 191 | "pv_power": { 192 | "value": 500, 193 | "unit": "W", 194 | }, 195 | "show": {"value": 1}, 196 | "status_code": {"value": 7}, 197 | "unique_id": {"value": "38183"}, 198 | }, 199 | { 200 | "device_id": {"value": "2"}, 201 | "custom_name": {"value": "Primo 5.0-1 20"}, 202 | "device_type": { 203 | "value": 86, 204 | "manufacturer": "Fronius", 205 | "model": "Primo 5.0-1 208-240", 206 | }, 207 | "error_code": {"value": 0}, 208 | "pv_power": { 209 | "value": 500, 210 | "unit": "W", 211 | }, 212 | "show": {"value": 1}, 213 | "status_code": {"value": 7}, 214 | "unique_id": {"value": "16777215"}, 215 | }, 216 | { 217 | "device_id": {"value": "3"}, 218 | "custom_name": {"value": "Galvo 3.1-1 20"}, 219 | "device_type": { 220 | "value": 106, 221 | "manufacturer": "Fronius", 222 | "model": "Galvo 3.1-1 208-240", 223 | }, 224 | "error_code": {"value": 0}, 225 | "pv_power": { 226 | "value": 500, 227 | "unit": "W", 228 | }, 229 | "show": {"value": 1}, 230 | "status_code": {"value": 7}, 231 | "unique_id": {"value": "7262"}, 232 | }, 233 | { 234 | "device_id": {"value": "55"}, 235 | "custom_name": {"value": "Galvo 3.0-1 (5"}, 236 | "device_type": { 237 | "value": 224, 238 | "manufacturer": "Fronius", 239 | "model": "Galvo 3.0-1", 240 | }, 241 | "error_code": {"value": 0}, 242 | "pv_power": { 243 | "value": 500, 244 | "unit": "W", 245 | }, 246 | "show": {"value": 1}, 247 | "status_code": {"value": 7}, 248 | "unique_id": {"value": "100372"}, 249 | }, 250 | { 251 | "device_id": {"value": "240"}, 252 | "custom_name": {"value": "tr-3pn-01"}, 253 | "device_type": { 254 | "value": 1, 255 | "manufacturer": "Fronius", 256 | "model": "Gen24", 257 | }, 258 | "pv_power": { 259 | "value": 0, 260 | "unit": "W", 261 | }, 262 | "show": {"value": 1}, 263 | "status_code": {"value": "Running"}, 264 | "unique_id": {"value": "29301000987160033"}, 265 | }, 266 | ], 267 | } 268 | -------------------------------------------------------------------------------- /pyfronius/const.py: -------------------------------------------------------------------------------- 1 | """Constants for pyfronius.""" 2 | 3 | from typing import Final 4 | 5 | INVERTER_DEVICE_TYPE: Final = { 6 | 1: {"manufacturer": "Fronius", "model": "Gen24"}, 7 | 42: {"manufacturer": "Fronius", "model": "Symo Advanced 10.0-3-M"}, 8 | 43: {"manufacturer": "Fronius", "model": "Symo Advanced 20.0-3-M"}, 9 | 44: {"manufacturer": "Fronius", "model": "Symo Advanced 17.5-3-M"}, 10 | 45: {"manufacturer": "Fronius", "model": "Symo Advanced 15.0-3-M"}, 11 | 46: {"manufacturer": "Fronius", "model": "Symo Advanced 12.5-3-M"}, 12 | 47: {"manufacturer": "Fronius", "model": "Symo Advanced 24.0-3 480"}, 13 | 48: {"manufacturer": "Fronius", "model": "Symo Advanced 22.7-3 480"}, 14 | 49: {"manufacturer": "Fronius", "model": "Symo Advanced 20.0-3 480"}, 15 | 50: {"manufacturer": "Fronius", "model": "Symo Advanced 15.0-3 480"}, 16 | 51: {"manufacturer": "Fronius", "model": "Symo Advanced 12.0-3 208-240"}, 17 | 52: {"manufacturer": "Fronius", "model": "Symo Advanced 10.0-3 208-240"}, 18 | 67: {"manufacturer": "Fronius", "model": "Primo 15.0-1 208-240"}, 19 | 68: {"manufacturer": "Fronius", "model": "Primo 12.5-1 208-240"}, 20 | 69: {"manufacturer": "Fronius", "model": "Primo 11.4-1 208-240"}, 21 | 70: {"manufacturer": "Fronius", "model": "Primo 10.0-1 208-240"}, 22 | 71: {"manufacturer": "Fronius", "model": "Symo 15.0-3 208"}, 23 | 72: {"manufacturer": "Fronius", "model": "Eco 27.0-3-S"}, 24 | 73: {"manufacturer": "Fronius", "model": "Eco 25.0-3-S"}, 25 | 75: {"manufacturer": "Fronius", "model": "Primo 6.0-1"}, 26 | 76: {"manufacturer": "Fronius", "model": "Primo 5.0-1"}, 27 | 77: {"manufacturer": "Fronius", "model": "Primo 4.6-1"}, 28 | 78: {"manufacturer": "Fronius", "model": "Primo 4.0-1"}, 29 | 79: {"manufacturer": "Fronius", "model": "Primo 3.6-1"}, 30 | 80: {"manufacturer": "Fronius", "model": "Primo 3.5-1"}, 31 | 81: {"manufacturer": "Fronius", "model": "Primo 3.0-1"}, 32 | 82: {"manufacturer": "Fronius", "model": "Symo Hybrid 4.0-3-S"}, 33 | 83: {"manufacturer": "Fronius", "model": "Symo Hybrid 3.0-3-S"}, 34 | 84: {"manufacturer": "Fronius", "model": "IG Plus 120 V-1"}, 35 | 85: {"manufacturer": "Fronius", "model": "Primo 3.8-1 208-240"}, 36 | 86: {"manufacturer": "Fronius", "model": "Primo 5.0-1 208-240"}, 37 | 87: {"manufacturer": "Fronius", "model": "Primo 6.0-1 208-240"}, 38 | 88: {"manufacturer": "Fronius", "model": "Primo 7.6-1 208-240"}, 39 | 89: {"manufacturer": "Fronius", "model": "Symo 24.0-3 USA Dummy"}, 40 | 90: {"manufacturer": "Fronius", "model": "Symo 24.0-3 480"}, 41 | 91: {"manufacturer": "Fronius", "model": "Symo 22.7-3 480"}, 42 | 92: {"manufacturer": "Fronius", "model": "Symo 20.0-3 480"}, 43 | 93: {"manufacturer": "Fronius", "model": "Symo 17.5-3 480"}, 44 | 94: {"manufacturer": "Fronius", "model": "Symo 15.0-3 480"}, 45 | 95: {"manufacturer": "Fronius", "model": "Symo 12.5-3 480"}, 46 | 96: {"manufacturer": "Fronius", "model": "Symo 10.0-3 480"}, 47 | 97: {"manufacturer": "Fronius", "model": "Symo 12.0-3 208-240"}, 48 | 98: {"manufacturer": "Fronius", "model": "Symo 10.0-3 208-240"}, 49 | 99: {"manufacturer": "Fronius", "model": "Symo Hybrid 5.0-3-S"}, 50 | 100: {"manufacturer": "Fronius", "model": "Primo 8.2-1 Dummy"}, 51 | 101: {"manufacturer": "Fronius", "model": "Primo 8.2-1 208-240"}, 52 | 102: {"manufacturer": "Fronius", "model": "Primo 8.2-1"}, 53 | 103: {"manufacturer": "Fronius", "model": "Agilo TL 360.0-3"}, 54 | 104: {"manufacturer": "Fronius", "model": "Agilo TL 460.0-3"}, 55 | 105: {"manufacturer": "Fronius", "model": "Symo 7.0-3-M"}, 56 | 106: {"manufacturer": "Fronius", "model": "Galvo 3.1-1 208-240"}, 57 | 107: {"manufacturer": "Fronius", "model": "Galvo 2.5-1 208-240"}, 58 | 108: {"manufacturer": "Fronius", "model": "Galvo 2.0-1 208-240"}, 59 | 109: {"manufacturer": "Fronius", "model": "Galvo 1.5-1 208-240"}, 60 | 110: {"manufacturer": "Fronius", "model": "Symo 6.0-3-M"}, 61 | 111: {"manufacturer": "Fronius", "model": "Symo 4.5-3-M"}, 62 | 112: {"manufacturer": "Fronius", "model": "Symo 3.7-3-M"}, 63 | 113: {"manufacturer": "Fronius", "model": "Symo 3.0-3-M"}, 64 | 114: {"manufacturer": "Fronius", "model": "Symo 17.5-3-M"}, 65 | 115: {"manufacturer": "Fronius", "model": "Symo 15.0-3-M"}, 66 | 116: {"manufacturer": "Fronius", "model": "Agilo 75.0-3 Outdoor"}, 67 | 117: {"manufacturer": "Fronius", "model": "Agilo 100.0-3 Outdoor"}, 68 | 118: {"manufacturer": "Fronius", "model": "IG Plus 55 V-1"}, 69 | 119: {"manufacturer": "Fronius", "model": "IG Plus 55 V-2"}, 70 | 120: {"manufacturer": "Fronius", "model": "Symo 20.0-3 Dummy"}, 71 | 121: {"manufacturer": "Fronius", "model": "Symo 20.0-3-M"}, 72 | 122: {"manufacturer": "Fronius", "model": "Symo 5.0-3-M"}, 73 | 123: {"manufacturer": "Fronius", "model": "Symo 8.2-3-M"}, 74 | 124: {"manufacturer": "Fronius", "model": "Symo 6.7-3-M"}, 75 | 125: {"manufacturer": "Fronius", "model": "Symo 5.5-3-M"}, 76 | 126: {"manufacturer": "Fronius", "model": "Symo 4.5-3-S"}, 77 | 127: {"manufacturer": "Fronius", "model": "Symo 3.7-3-S"}, 78 | 128: {"manufacturer": "Fronius", "model": "IG Plus 60 V-2"}, 79 | 129: {"manufacturer": "Fronius", "model": "IG Plus 60 V-1"}, 80 | 130: {"manufacturer": "SunPower", "model": "SPR 8001F-3 EU"}, 81 | 131: {"manufacturer": "Fronius", "model": "IG Plus 25 V-1"}, 82 | 132: {"manufacturer": "Fronius", "model": "IG Plus 100 V-3"}, 83 | 133: {"manufacturer": "Fronius", "model": "Agilo 100.0-3"}, 84 | 134: {"manufacturer": "SunPower", "model": "SPR 3001F-1 EU"}, 85 | 135: {"manufacturer": "Fronius", "model": "IG Plus V/A 10.0-3 Delta"}, 86 | 136: {"manufacturer": "Fronius", "model": "IG 50"}, 87 | 137: {"manufacturer": "Fronius", "model": "IG Plus 30 V-1"}, 88 | 138: {"manufacturer": "SunPower", "model": "SPR-11401f-1 UNI"}, 89 | 139: {"manufacturer": "SunPower", "model": "SPR-12001f-3 WYE277"}, 90 | 140: {"manufacturer": "SunPower", "model": "SPR-11401f-3 Delta"}, 91 | 141: {"manufacturer": "SunPower", "model": "SPR-10001f-1 UNI"}, 92 | 142: {"manufacturer": "SunPower", "model": "SPR-7501f-1 UNI"}, 93 | 143: {"manufacturer": "SunPower", "model": "SPR-6501f-1 UNI"}, 94 | 144: {"manufacturer": "SunPower", "model": "SPR-3801f-1 UNI"}, 95 | 145: {"manufacturer": "SunPower", "model": "SPR-3301f-1 UNI"}, 96 | 146: {"manufacturer": "SunPower", "model": "SPR 12001F-3 EU"}, 97 | 147: {"manufacturer": "SunPower", "model": "SPR 10001F-3 EU"}, 98 | 148: {"manufacturer": "SunPower", "model": "SPR 8001F-2 EU"}, 99 | 149: {"manufacturer": "SunPower", "model": "SPR 6501F-2 EU"}, 100 | 150: {"manufacturer": "SunPower", "model": "SPR 4001F-1 EU"}, 101 | 151: {"manufacturer": "SunPower", "model": "SPR 3501F-1 EU"}, 102 | 152: {"manufacturer": "Fronius", "model": "CL 60.0 WYE277 Dummy"}, 103 | 153: {"manufacturer": "Fronius", "model": "CL 55.5 Delta Dummy"}, 104 | 154: {"manufacturer": "Fronius", "model": "CL 60.0 Dummy"}, 105 | 155: {"manufacturer": "Fronius", "model": "IG Plus V 12.0-3 Dummy"}, 106 | 156: {"manufacturer": "Fronius", "model": "IG Plus V 7.5-1 Dummy"}, 107 | 157: {"manufacturer": "Fronius", "model": "IG Plus V 3.8-1 Dummy"}, 108 | 158: {"manufacturer": "Fronius", "model": "IG Plus 150 V-3 Dummy"}, 109 | 159: {"manufacturer": "Fronius", "model": "IG Plus 100 V-2 Dummy"}, 110 | 160: {"manufacturer": "Fronius", "model": "IG Plus 50 V-1 Dummy"}, 111 | 161: {"manufacturer": "Fronius", "model": "IG Plus V/A 12.0-3 WYE"}, 112 | 162: {"manufacturer": "Fronius", "model": "IG Plus V/A 11.4-3 Delta"}, 113 | 163: {"manufacturer": "Fronius", "model": "IG Plus V/A 11.4-1 UNI"}, 114 | 164: {"manufacturer": "Fronius", "model": "IG Plus V/A 10.0-1 UNI"}, 115 | 165: {"manufacturer": "Fronius", "model": "IG Plus V/A 7.5-1 UNI"}, 116 | 166: {"manufacturer": "Fronius", "model": "IG Plus V/A 6.0-1 UNI"}, 117 | 167: {"manufacturer": "Fronius", "model": "IG Plus V/A 5.0-1 UNI"}, 118 | 168: {"manufacturer": "Fronius", "model": "IG Plus V/A 3.8-1 UNI"}, 119 | 169: {"manufacturer": "Fronius", "model": "IG Plus V/A 3.0-1 UNI"}, 120 | 170: {"manufacturer": "Fronius", "model": "IG Plus 150 V-3"}, 121 | 171: {"manufacturer": "Fronius", "model": "IG Plus 120 V-3"}, 122 | 172: {"manufacturer": "Fronius", "model": "IG Plus 100 V-2"}, 123 | 173: {"manufacturer": "Fronius", "model": "IG Plus 100 V-1"}, 124 | 174: {"manufacturer": "Fronius", "model": "IG Plus 70 V-2"}, 125 | 175: {"manufacturer": "Fronius", "model": "IG Plus 70 V-1"}, 126 | 176: {"manufacturer": "Fronius", "model": "IG Plus 50 V-1"}, 127 | 177: {"manufacturer": "Fronius", "model": "IG Plus 35 V-1"}, 128 | 178: {"manufacturer": "SunPower", "model": "SPR 11400f-3 208/240"}, 129 | 179: {"manufacturer": "SunPower", "model": "SPR 12000f-277"}, 130 | 180: {"manufacturer": "SunPower", "model": "SPR 10000f"}, 131 | 181: {"manufacturer": "SunPower", "model": "SPR 10000F EU"}, 132 | 182: {"manufacturer": "Fronius", "model": "CL 33.3 Delta"}, 133 | 183: {"manufacturer": "Fronius", "model": "CL 44.4 Delta"}, 134 | 184: {"manufacturer": "Fronius", "model": "CL 55.5 Delta"}, 135 | 185: {"manufacturer": "Fronius", "model": "CL 36.0 WYE277"}, 136 | 186: {"manufacturer": "Fronius", "model": "CL 48.0 WYE277"}, 137 | 187: {"manufacturer": "Fronius", "model": "CL 60.0 WYE277"}, 138 | 188: {"manufacturer": "Fronius", "model": "CL 36.0"}, 139 | 189: {"manufacturer": "Fronius", "model": "CL 48.0"}, 140 | 190: {"manufacturer": "Fronius", "model": "IG TL 3.0"}, 141 | 191: {"manufacturer": "Fronius", "model": "IG TL 4.0"}, 142 | 192: {"manufacturer": "Fronius", "model": "IG TL 5.0"}, 143 | 193: {"manufacturer": "Fronius", "model": "IG TL 3.6"}, 144 | 194: {"manufacturer": "Fronius", "model": "IG TL Dummy"}, 145 | 195: {"manufacturer": "Fronius", "model": "IG TL 4.6"}, 146 | 196: {"manufacturer": "SunPower", "model": "SPR 12000F EU"}, 147 | 197: {"manufacturer": "SunPower", "model": "SPR 8000F EU"}, 148 | 198: {"manufacturer": "SunPower", "model": "SPR 6500F EU"}, 149 | 199: {"manufacturer": "SunPower", "model": "SPR 4000F EU"}, 150 | 200: {"manufacturer": "SunPower", "model": "SPR 3300F EU"}, 151 | 201: {"manufacturer": "Fronius", "model": "CL 60.0"}, 152 | 202: {"manufacturer": "SunPower", "model": "SPR 12000f"}, 153 | 203: {"manufacturer": "SunPower", "model": "SPR 8000f"}, 154 | 204: {"manufacturer": "SunPower", "model": "SPR 6500f"}, 155 | 205: {"manufacturer": "SunPower", "model": "SPR 4000f"}, 156 | 206: {"manufacturer": "SunPower", "model": "SPR 3300f"}, 157 | 207: {"manufacturer": "Fronius", "model": "IG Plus 12.0-3 WYE277"}, 158 | 208: {"manufacturer": "Fronius", "model": "IG Plus 50"}, 159 | 209: {"manufacturer": "Fronius", "model": "IG Plus 100"}, 160 | 210: {"manufacturer": "Fronius", "model": "IG Plus 100"}, 161 | 211: {"manufacturer": "Fronius", "model": "IG Plus 150"}, 162 | 212: {"manufacturer": "Fronius", "model": "IG Plus 35"}, 163 | 213: {"manufacturer": "Fronius", "model": "IG Plus 70"}, 164 | 214: {"manufacturer": "Fronius", "model": "IG Plus 70"}, 165 | 215: {"manufacturer": "Fronius", "model": "IG Plus 120"}, 166 | 216: {"manufacturer": "Fronius", "model": "IG Plus 3.0-1 UNI"}, 167 | 217: {"manufacturer": "Fronius", "model": "IG Plus 3.8-1 UNI"}, 168 | 218: {"manufacturer": "Fronius", "model": "IG Plus 5.0-1 UNI"}, 169 | 219: {"manufacturer": "Fronius", "model": "IG Plus 6.0-1 UNI"}, 170 | 220: {"manufacturer": "Fronius", "model": "IG Plus 7.5-1 UNI"}, 171 | 221: {"manufacturer": "Fronius", "model": "IG Plus 10.0-1 UNI"}, 172 | 222: {"manufacturer": "Fronius", "model": "IG Plus 11.4-1 UNI"}, 173 | 223: {"manufacturer": "Fronius", "model": "IG Plus 11.4-3 Delta"}, 174 | 224: {"manufacturer": "Fronius", "model": "Galvo 3.0-1"}, 175 | 225: {"manufacturer": "Fronius", "model": "Galvo 2.5-1"}, 176 | 226: {"manufacturer": "Fronius", "model": "Galvo 2.0-1"}, 177 | 227: {"manufacturer": "Fronius", "model": "IG 4500-LV"}, 178 | 228: {"manufacturer": "Fronius", "model": "Galvo 1.5-1"}, 179 | 229: {"manufacturer": "Fronius", "model": "IG 2500-LV"}, 180 | 230: {"manufacturer": "Fronius", "model": "Agilo 75.0-3"}, 181 | 231: {"manufacturer": "Fronius", "model": "Agilo 100.0-3 Dummy"}, 182 | 232: {"manufacturer": "Fronius", "model": "Symo 10.0-3-M"}, 183 | 233: {"manufacturer": "Fronius", "model": "Symo 12.5-3-M"}, 184 | 234: {"manufacturer": "Fronius", "model": "IG 5100"}, 185 | 235: {"manufacturer": "Fronius", "model": "IG 4000"}, 186 | 236: {"manufacturer": "Fronius", "model": "Symo 8.2-3-M Dummy"}, 187 | 237: {"manufacturer": "Fronius", "model": "IG 3000"}, 188 | 238: {"manufacturer": "Fronius", "model": "IG 2000"}, 189 | 239: {"manufacturer": "Fronius", "model": "Galvo 3.1-1 Dummy"}, 190 | 240: {"manufacturer": "Fronius", "model": "IG Plus 80 V-3"}, 191 | 241: {"manufacturer": "Fronius", "model": "IG Plus 60 V-3"}, 192 | 242: {"manufacturer": "Fronius", "model": "IG Plus 55 V-3"}, 193 | 243: {"manufacturer": "Fronius", "model": "IG 60 ADV"}, 194 | 244: {"manufacturer": "Fronius", "model": "IG 500"}, 195 | 245: {"manufacturer": "Fronius", "model": "IG 400"}, 196 | 246: {"manufacturer": "Fronius", "model": "IG 300"}, 197 | 247: {"manufacturer": "Fronius", "model": "Symo 3.0-3-S"}, 198 | 248: {"manufacturer": "Fronius", "model": "Galvo 3.1-1"}, 199 | 249: {"manufacturer": "Fronius", "model": "IG 60 HV"}, 200 | 250: {"manufacturer": "Fronius", "model": "IG 40"}, 201 | 251: {"manufacturer": "Fronius", "model": "IG 30 Dummy"}, 202 | 252: {"manufacturer": "Fronius", "model": "IG 30"}, 203 | 253: {"manufacturer": "Fronius", "model": "IG 20"}, 204 | 254: {"manufacturer": "Fronius", "model": "IG 15"}, 205 | } 206 | 207 | OHMPILOT_STATE_CODES: Final = { 208 | 0: "Up and running", 209 | 1: "Keep minimum temperature", 210 | 2: "Legionella protection", 211 | 3: "Critical fault", 212 | 4: "Fault", 213 | 5: "Boost mode", 214 | } 215 | -------------------------------------------------------------------------------- /pyfronius/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Created on 27.09.2017 3 | 4 | @author: Niels 5 | @author: Gerrit Beine 6 | """ 7 | 8 | import asyncio 9 | import enum 10 | import json 11 | import logging 12 | from html import unescape 13 | from typing import Any, Callable, Dict, Final, Iterable, List, Tuple, Union 14 | 15 | import aiohttp 16 | 17 | from .const import INVERTER_DEVICE_TYPE, OHMPILOT_STATE_CODES 18 | 19 | _LOGGER = logging.getLogger(__name__) 20 | DEGREE_CELSIUS: Final = "°C" 21 | WATT: Final = "W" 22 | WATT_HOUR: Final = "Wh" 23 | AMPERE: Final = "A" 24 | VOLT: Final = "V" 25 | PERCENT: Final = "%" 26 | HERTZ: Final = "Hz" 27 | VOLTAMPEREREACTIVE: Final = "VAr" 28 | VOLTAMPEREREACTIVE_HOUR: Final = "VArh" 29 | VOLTAMPERE: Final = "VA" 30 | 31 | 32 | class API_VERSION(enum.Enum): 33 | value: int 34 | 35 | AUTO = -1 36 | V0 = 0 37 | V1 = 1 38 | 39 | 40 | API_BASEPATHS: Final = { 41 | API_VERSION.V0: "/solar_api/", 42 | API_VERSION.V1: "/solar_api/v1/", 43 | } 44 | 45 | URL_API_VERSION: Final = "solar_api/GetAPIVersion.cgi" 46 | URL_POWER_FLOW: Final = {API_VERSION.V1: "GetPowerFlowRealtimeData.fcgi"} 47 | URL_SYSTEM_METER: Final = {API_VERSION.V1: "GetMeterRealtimeData.cgi?Scope=System"} 48 | URL_SYSTEM_INVERTER: Final = { 49 | API_VERSION.V0: "GetInverterRealtimeData.cgi?Scope=System", 50 | API_VERSION.V1: "GetInverterRealtimeData.cgi?Scope=System", 51 | } 52 | URL_SYSTEM_LED: Final = {API_VERSION.V1: "GetLoggerLEDInfo.cgi"} 53 | URL_SYSTEM_OHMPILOT: Final = { 54 | API_VERSION.V1: "GetOhmPilotRealtimeData.cgi?Scope=System" 55 | } 56 | URL_SYSTEM_STORAGE: Final = {API_VERSION.V1: "GetStorageRealtimeData.cgi?Scope=System"} 57 | URL_DEVICE_METER: Final = { 58 | API_VERSION.V1: "GetMeterRealtimeData.cgi?Scope=Device&DeviceId={}" 59 | } 60 | URL_DEVICE_STORAGE: Final = { 61 | API_VERSION.V1: "GetStorageRealtimeData.cgi?Scope=Device&DeviceId={}" 62 | } 63 | URL_DEVICE_INVERTER_CUMULATIVE: Final = { 64 | API_VERSION.V0: ( 65 | "GetInverterRealtimeData.cgi?Scope=Device&" 66 | "DeviceIndex={}&" 67 | "DataCollection=CumulationInverterData" 68 | ), 69 | API_VERSION.V1: ( 70 | "GetInverterRealtimeData.cgi?Scope=Device&" 71 | "DeviceId={}&" 72 | "DataCollection=CumulationInverterData" 73 | ), 74 | } 75 | URL_DEVICE_INVERTER_COMMON: Final = { 76 | API_VERSION.V0: ( 77 | "GetInverterRealtimeData.cgi?Scope=Device&" 78 | "DeviceIndex={}&" 79 | "DataCollection=CommonInverterData" 80 | ), 81 | API_VERSION.V1: ( 82 | "GetInverterRealtimeData.cgi?Scope=Device&" 83 | "DeviceId={}&" 84 | "DataCollection=CommonInverterData" 85 | ), 86 | } 87 | URL_DEVICE_INVERTER_3P: Final = { 88 | API_VERSION.V1: ( 89 | "GetInverterRealtimeData.cgi?Scope=Device&" 90 | "DeviceId={}&" 91 | "DataCollection=3PInverterData" 92 | ), 93 | } 94 | URL_ACTIVE_DEVICE_INFO_SYSTEM: Final = { 95 | API_VERSION.V1: "GetActiveDeviceInfo.cgi?DeviceClass=System" 96 | } 97 | URL_INVERTER_INFO: Final = { 98 | API_VERSION.V0: "GetInverterInfo.cgi", 99 | API_VERSION.V1: "GetInverterInfo.cgi", 100 | } 101 | URL_LOGGER_INFO: Final = { 102 | API_VERSION.V0: "GetLoggerInfo.cgi", 103 | API_VERSION.V1: "GetLoggerInfo.cgi", 104 | } 105 | 106 | HEADER_STATUS_CODES: Final = { 107 | 0: "OKAY", 108 | 1: "NotImplemented", 109 | 2: "Uninitialized", 110 | 3: "Initialized", 111 | 4: "Running", 112 | 5: "Timeout", 113 | 6: "Argument Error", 114 | 7: "LNRequestError", 115 | 8: "LNRequestTimeout", 116 | 9: "LNParseError", 117 | 10: "ConfigIOError", 118 | 11: "NotSupported", 119 | 12: "DeviceNotAvailable", 120 | 255: "UnknownError", 121 | } 122 | 123 | 124 | class FroniusError(Exception): 125 | """ 126 | A superclass that covers all errors occuring during the 127 | connection to a Fronius device 128 | """ 129 | 130 | 131 | class NotSupportedError(ValueError, FroniusError): 132 | """ 133 | An error to be raised if a specific feature 134 | is not supported by the specified device 135 | """ 136 | 137 | 138 | class FroniusConnectionError(ConnectionError, FroniusError): 139 | """ 140 | An error to be raised if the connection to the fronius device failed 141 | """ 142 | 143 | 144 | class InvalidAnswerError(ValueError, FroniusError): 145 | """ 146 | An error to be raised if the host Fronius device could not answer a request 147 | """ 148 | 149 | 150 | class BadStatusError(FroniusError): 151 | """A bad status code was returned.""" 152 | 153 | def __init__( 154 | self, 155 | endpoint: str, 156 | code: int, 157 | reason: Union[str, None] = None, 158 | response: Dict[str, Any] = {}, 159 | ) -> None: 160 | """Instantiate exception.""" 161 | self.response = response 162 | message = ( 163 | f"BadStatusError at {endpoint}. " 164 | f"Code: {code} - {HEADER_STATUS_CODES.get(code, 'unknown status code')}. " 165 | f"Reason: {reason or 'unknown'}." 166 | ) 167 | super().__init__(message) 168 | 169 | 170 | class Fronius: 171 | """ 172 | Interface to communicate with the Fronius Symo over http / JSON 173 | Timeouts are to be set in the given AIO session 174 | Attributes: 175 | session The AIO session 176 | url The url for reaching of the Fronius device 177 | (i.e. http://192.168.0.10:80) 178 | api_version Version of Fronius API to use 179 | """ 180 | 181 | def __init__( 182 | self, 183 | session: aiohttp.ClientSession, 184 | url: str, 185 | api_version: API_VERSION = API_VERSION.AUTO, 186 | ) -> None: 187 | """ 188 | Constructor 189 | """ 190 | self._aio_session = session 191 | while url[-1] == "/": 192 | url = url[:-1] 193 | self.url = url 194 | # prepend http:// if missing, by fronius API this is the only supported protocol 195 | if not self.url.startswith("http"): 196 | self.url = "http://{}".format(self.url) 197 | self.api_version = api_version 198 | self.base_url = API_BASEPATHS.get(api_version) 199 | 200 | async def _fetch_json(self, url: str) -> Dict[str, Any]: 201 | """ 202 | Fetch json value from fixed url 203 | """ 204 | result: Dict[str, Any] 205 | try: 206 | async with self._aio_session.get(url) as res: 207 | result = await res.json(content_type=None) 208 | except asyncio.TimeoutError: 209 | raise FroniusConnectionError( 210 | "Connection to Fronius device timed out at {}.".format(url) 211 | ) 212 | except aiohttp.ClientError: 213 | raise FroniusConnectionError( 214 | "Connection to Fronius device failed at {}.".format(url) 215 | ) 216 | except (aiohttp.ContentTypeError, json.decoder.JSONDecodeError): 217 | raise InvalidAnswerError( 218 | "Host returned a non-JSON reply at {}.".format(url) 219 | ) 220 | return result 221 | 222 | async def fetch_api_version(self) -> Tuple[API_VERSION, str]: 223 | """ 224 | Fetches the highest supported API version of the initiated fronius device 225 | :return: 226 | """ 227 | try: 228 | res = await self._fetch_json("{}/{}".format(self.url, URL_API_VERSION)) 229 | api_version, base_url = API_VERSION(res["APIVersion"]), res["BaseURL"] 230 | except InvalidAnswerError: 231 | # Host returns 404 response if API version is 0 232 | api_version, base_url = API_VERSION.V0, API_BASEPATHS[API_VERSION.V0] 233 | 234 | return api_version, base_url 235 | 236 | async def _fetch_solar_api( 237 | self, spec: Dict[API_VERSION, str], spec_name: str, *spec_formattings: str 238 | ) -> Dict[str, Any]: 239 | """ 240 | Fetch page of solar_api 241 | """ 242 | # either unknown api version given or automatic 243 | if self.base_url is None: 244 | prev_api_version = self.api_version 245 | self.api_version, self.base_url = await self.fetch_api_version() 246 | if prev_api_version == API_VERSION.AUTO: 247 | _LOGGER.debug( 248 | """using highest supported API version {}""".format( 249 | self.api_version 250 | ) 251 | ) 252 | if ( 253 | prev_api_version != self.api_version 254 | and prev_api_version != API_VERSION.AUTO 255 | ): 256 | _LOGGER.warning( 257 | ( 258 | """Unknown API version {} is not supported by host {},""" 259 | """using highest supported API version {} instead""" 260 | ).format(prev_api_version, self.url, self.api_version) 261 | ) 262 | spec_url = spec.get(self.api_version) 263 | if spec_url is None: 264 | raise NotSupportedError( 265 | "API version {} does not support request of {} data".format( 266 | self.api_version, spec_name 267 | ) 268 | ) 269 | if spec_formattings: 270 | spec_url = spec_url.format(*spec_formattings) 271 | 272 | _LOGGER.debug("Get {} data for {}".format(spec_name, spec_url)) 273 | res = await self._fetch_json("{}{}{}".format(self.url, self.base_url, spec_url)) 274 | return res 275 | 276 | async def fetch( 277 | self, 278 | active_device_info: bool = True, 279 | inverter_info: bool = True, 280 | logger_info: bool = True, 281 | power_flow: bool = True, 282 | system_meter: bool = True, 283 | system_inverter: bool = True, 284 | system_ohmpilot: bool = True, 285 | system_storage: bool = True, 286 | device_meter: Iterable[str] = frozenset(["0"]), 287 | # storage is not necessarily supported by every fronius device 288 | device_storage: Iterable[str] = frozenset(["0"]), 289 | device_inverter: Iterable[str] = frozenset(["1"]), 290 | ) -> List[Dict[str, Any]]: 291 | requests = [] 292 | if active_device_info: 293 | requests.append(self.current_active_device_info()) 294 | if inverter_info: 295 | requests.append(self.inverter_info()) 296 | if logger_info: 297 | requests.append(self.current_logger_info()) 298 | if power_flow: 299 | requests.append(self.current_power_flow()) 300 | if system_meter: 301 | requests.append(self.current_system_meter_data()) 302 | if system_inverter: 303 | requests.append(self.current_system_inverter_data()) 304 | if system_ohmpilot: 305 | requests.append(self.current_system_ohmpilot_data()) 306 | if system_storage: 307 | requests.append(self.current_system_storage_data()) 308 | for i in device_meter: 309 | requests.append(self.current_meter_data(i)) 310 | for i in device_storage: 311 | requests.append(self.current_storage_data(i)) 312 | for i in device_inverter: 313 | requests.append(self.current_inverter_data(i)) 314 | for i in device_inverter: 315 | requests.append(self.current_inverter_3p_data(i)) 316 | 317 | res = await asyncio.gather(*requests, return_exceptions=True) 318 | responses = [] 319 | for result in res: 320 | if isinstance(result, (FroniusError, BaseException)): 321 | _LOGGER.warning(result) 322 | if isinstance(result, BadStatusError): 323 | responses.append(result.response) 324 | continue 325 | responses.append(result) 326 | return responses 327 | 328 | @staticmethod 329 | def _status_data(res: Dict[str, Any]) -> Dict[str, Any]: 330 | sensor = {} 331 | 332 | sensor["timestamp"] = {"value": res["Head"]["Timestamp"]} 333 | sensor["status"] = res["Head"]["Status"] 334 | 335 | return sensor 336 | 337 | @staticmethod 338 | def error_code(sensor_data: Dict[str, Any]) -> Any: 339 | """ 340 | Extract error code from returned sensor data 341 | :param sensor_data: Dictionary returned as current data 342 | """ 343 | return sensor_data["status"]["Code"] 344 | 345 | @staticmethod 346 | def error_reason(sensor_data: Dict[str, Any]) -> Any: 347 | """ 348 | Extract error reason from returned sensor data 349 | :param sensor_data: Dictionary returned as current data 350 | """ 351 | return sensor_data["status"]["Reason"] 352 | 353 | async def _current_data( 354 | self, 355 | fun: Callable[[Dict[str, Any]], Dict[str, Any]], 356 | spec: Dict[API_VERSION, str], 357 | spec_name: str, 358 | *spec_formattings: str, 359 | ) -> Dict[str, Any]: 360 | sensor = {} 361 | try: 362 | res = await self._fetch_solar_api(spec, spec_name, *spec_formattings) 363 | except InvalidAnswerError: 364 | # except if Host returns 404 365 | raise NotSupportedError( 366 | "Device type {} not supported by the fronius device".format(spec_name) 367 | ) 368 | 369 | try: 370 | sensor.update(Fronius._status_data(res)) 371 | except (TypeError, KeyError): 372 | raise InvalidAnswerError( 373 | "No header data returned from {} ({})".format(spec, spec_formattings) 374 | ) 375 | else: 376 | if sensor["status"]["Code"] != 0: 377 | endpoint = spec[self.api_version] 378 | code = sensor["status"]["Code"] 379 | reason = sensor["status"]["Reason"] 380 | raise BadStatusError(endpoint, code, reason=reason, response=sensor) 381 | try: 382 | sensor.update(fun(res["Body"]["Data"])) 383 | except (TypeError, KeyError): 384 | # LoggerInfo oddly deviates from the default scheme 385 | try: 386 | sensor.update(fun(res["Body"]["LoggerInfo"])) 387 | except (TypeError, KeyError): 388 | raise InvalidAnswerError( 389 | "No body data returned from {} ({})".format(spec, spec_formattings) 390 | ) 391 | return sensor 392 | 393 | async def current_power_flow( 394 | self, 395 | ext_cb_conversion: Callable[[Dict[str, Any]], Dict[str, Any]] = None 396 | ) -> Dict[str, Any]: 397 | """ 398 | Get the current power flow of a smart meter system. 399 | """ 400 | cb = Fronius._system_power_flow 401 | if ext_cb_conversion is not None: 402 | cb = ext_cb_conversion 403 | return await self._current_data(cb, URL_POWER_FLOW, "current power flow") 404 | 405 | async def current_system_meter_data( 406 | self, 407 | ext_cb_conversion: Callable[[Dict[str, Any]], Dict[str, Any]] = None 408 | ) -> Dict[str, Any]: 409 | """ 410 | Get the current meter data. 411 | """ 412 | cb = Fronius._system_meter_data 413 | if ext_cb_conversion is not None: 414 | cb = ext_cb_conversion 415 | return await self._current_data(cb, URL_SYSTEM_METER, "current system meter") 416 | 417 | async def current_system_inverter_data( 418 | self, 419 | ext_cb_conversion: Callable[[Dict[str, Any]], Dict[str, Any]] = None 420 | ) -> Dict[str, Any]: 421 | """ 422 | Get the current inverter data. 423 | The values are provided as cumulated values and for each inverter 424 | """ 425 | cb = Fronius._system_inverter_data 426 | if ext_cb_conversion is not None: 427 | cb = ext_cb_conversion 428 | return await self._current_data( 429 | cb, URL_SYSTEM_INVERTER, "current system inverter") 430 | 431 | async def current_system_ohmpilot_data( 432 | self, 433 | ext_cb_conversion: Callable[[Dict[str, Any]], Dict[str, Any]] = None 434 | ) -> Dict[str, Any]: 435 | """ 436 | Get the current ohmpilot data. 437 | """ 438 | cb = Fronius._system_ohmpilot_data 439 | if ext_cb_conversion is not None: 440 | cb = ext_cb_conversion 441 | return await self._current_data( 442 | cb, URL_SYSTEM_OHMPILOT, "current system ohmpilot") 443 | 444 | async def current_meter_data( 445 | self, 446 | device: str = "0", 447 | ext_cb_conversion: Callable[[Dict[str, Any]], Dict[str, Any]] = None 448 | ) -> Dict[str, Any]: 449 | """ 450 | Get the current meter data for a device. 451 | """ 452 | cb = Fronius._device_meter_data 453 | if ext_cb_conversion is not None: 454 | cb = ext_cb_conversion 455 | return await self._current_data(cb, URL_DEVICE_METER, "current meter", device) 456 | 457 | async def current_storage_data( 458 | self, 459 | device: str = "0", 460 | ext_cb_conversion: Callable[[Dict[str, Any]], Dict[str, Any]] = None 461 | ) -> Dict[str, Any]: 462 | """ 463 | Get the current storage data for a device. 464 | Provides data about batteries. 465 | """ 466 | cb = Fronius._device_storage_data 467 | if ext_cb_conversion is not None: 468 | cb = ext_cb_conversion 469 | return await self._current_data( 470 | cb, URL_DEVICE_STORAGE, "current storage", device) 471 | 472 | async def current_system_storage_data( 473 | self, 474 | ext_cb_conversion: Callable[[Dict[str, Any]], Dict[str, Any]] = None 475 | ) -> Dict[str, Any]: 476 | """ 477 | Get the current storage data for a device. 478 | Provides data about batteries. 479 | """ 480 | cb = Fronius._system_storage_data 481 | if ext_cb_conversion is not None: 482 | cb = ext_cb_conversion 483 | return await self._current_data( 484 | cb, URL_SYSTEM_STORAGE, "current system storage") 485 | 486 | async def current_inverter_data( 487 | self, 488 | device: str = "1", 489 | ext_cb_conversion: Callable[[Dict[str, Any]], Dict[str, Any]] = None 490 | ) -> Dict[str, Any]: 491 | """ 492 | Get the current inverter data of one device. 493 | """ 494 | cb = Fronius._device_inverter_data 495 | if ext_cb_conversion is not None: 496 | cb = ext_cb_conversion 497 | return await self._current_data( 498 | cb, URL_DEVICE_INVERTER_COMMON, "current inverter", device) 499 | 500 | async def current_inverter_3p_data( 501 | self, 502 | device: str = "1", 503 | ext_cb_conversion: Callable[[Dict[str, Any]], Dict[str, Any]] = None 504 | ) -> Dict[str, Any]: 505 | """ 506 | Get the current inverter 3 phase data of one device. 507 | """ 508 | cb = Fronius._device_inverter_3p_data 509 | if ext_cb_conversion is not None: 510 | cb = ext_cb_conversion 511 | return await self._current_data( 512 | cb, URL_DEVICE_INVERTER_3P, "current inverter 3p", device) 513 | 514 | async def current_led_data( 515 | self, 516 | ext_cb_conversion: Callable[[Dict[str, Any]], Dict[str, Any]] = None 517 | ) -> Dict[str, Any]: 518 | """ 519 | Get the current info led data for all LEDs 520 | """ 521 | cb = Fronius._system_led_data 522 | if ext_cb_conversion is not None: 523 | cb = ext_cb_conversion 524 | return await self._current_data(cb, URL_SYSTEM_LED, "current led") 525 | 526 | async def current_active_device_info( 527 | self, 528 | ext_cb_conversion: Callable[[Dict[str, Any]], Dict[str, Any]] = None 529 | ) -> Dict[str, Any]: 530 | """ 531 | Get info about the current active devices in a smart meter system. 532 | """ 533 | cb = Fronius._system_active_device_info 534 | if ext_cb_conversion is not None: 535 | cb = ext_cb_conversion 536 | return await self._current_data( 537 | cb, URL_ACTIVE_DEVICE_INFO_SYSTEM, "current active device info") 538 | 539 | async def current_logger_info( 540 | self, 541 | ext_cb_conversion: Callable[[Dict[str, Any]], Dict[str, Any]] = None 542 | ) -> Dict[str, Any]: 543 | """ 544 | Get the current logger info of a smart meter system. 545 | """ 546 | cb = Fronius._logger_info 547 | if ext_cb_conversion is not None: 548 | cb = ext_cb_conversion 549 | return await self._current_data(cb, URL_LOGGER_INFO, "current logger info") 550 | 551 | async def inverter_info( 552 | self, 553 | ext_cb_conversion: Callable[[Dict[str, Any]], Dict[str, Any]] = None 554 | ) -> Dict[str, Any]: 555 | """ 556 | Get the general infos of an inverter. 557 | """ 558 | cb = Fronius._inverter_info 559 | if ext_cb_conversion is not None: 560 | cb = ext_cb_conversion 561 | return await self._current_data(cb, URL_INVERTER_INFO, "inverter info") 562 | 563 | @staticmethod 564 | def _system_led_data(data: Dict[str, Any]) -> Dict[str, Any]: 565 | _LOGGER.debug("Converting system led data: '{}'".format(data)) 566 | sensor = {} 567 | 568 | _map = { 569 | "PowerLED": "power_led", 570 | "SolarNetLED": "solar_net_led", 571 | "SolarWebLED": "solar_web_led", 572 | "WLANLED": "wlan_led", 573 | } 574 | 575 | for led in _map: 576 | if led in data: 577 | sensor[_map[led]] = { 578 | "color": data[led]["Color"], 579 | "state": data[led]["State"], 580 | } 581 | 582 | return sensor 583 | 584 | @staticmethod 585 | def _system_power_flow(data: Dict[str, Any]) -> Dict[str, Any]: 586 | _LOGGER.debug("Converting system power flow data: '{}'".format(data)) 587 | sensor = {} 588 | 589 | site = data["Site"] 590 | # Backwards compatability 591 | if data["Inverters"].get("1"): 592 | inverter = data["Inverters"]["1"] 593 | if "Battery_Mode" in inverter: 594 | sensor["battery_mode"] = {"value": inverter["Battery_Mode"]} 595 | if "SOC" in inverter: 596 | sensor["state_of_charge"] = {"value": inverter["SOC"], "unit": PERCENT} 597 | 598 | for index, inverter in enumerate(data["Inverters"]): 599 | if "Battery_Mode" in inverter: 600 | sensor["battery_mode_{}".format(index)] = { 601 | "value": inverter["Battery_Mode"] 602 | } 603 | if "SOC" in inverter: 604 | sensor["state_of_charge_{}".format(index)] = { 605 | "value": inverter["SOC"], 606 | "unit": PERCENT, 607 | } 608 | 609 | if "BackupMode" in site: 610 | sensor["backup_mode"] = {"value": site["BackupMode"]} 611 | if "BatteryStandby" in site: 612 | sensor["battery_standby"] = {"value": site["BatteryStandby"]} 613 | if "E_Day" in site: 614 | sensor["energy_day"] = {"value": site["E_Day"], "unit": WATT_HOUR} 615 | if "E_Total" in site: 616 | sensor["energy_total"] = {"value": site["E_Total"], "unit": WATT_HOUR} 617 | if "E_Year" in site: 618 | sensor["energy_year"] = {"value": site["E_Year"], "unit": WATT_HOUR} 619 | if "Meter_Location" in site: 620 | sensor["meter_location"] = {"value": site["Meter_Location"]} 621 | if "Mode" in site: 622 | sensor["meter_mode"] = {"value": site["Mode"]} 623 | if "P_Akku" in site: 624 | sensor["power_battery"] = {"value": site["P_Akku"], "unit": WATT} 625 | if "P_Grid" in site: 626 | sensor["power_grid"] = {"value": site["P_Grid"], "unit": WATT} 627 | if "P_Load" in site: 628 | sensor["power_load"] = {"value": site["P_Load"], "unit": WATT} 629 | if "P_PV" in site: 630 | sensor["power_photovoltaics"] = {"value": site["P_PV"], "unit": WATT} 631 | if "rel_Autonomy" in site: 632 | sensor["relative_autonomy"] = { 633 | "value": site["rel_Autonomy"], 634 | "unit": PERCENT, 635 | } 636 | if "rel_SelfConsumption" in site: 637 | sensor["relative_self_consumption"] = { 638 | "value": site["rel_SelfConsumption"], 639 | "unit": PERCENT, 640 | } 641 | 642 | return sensor 643 | 644 | @staticmethod 645 | def _system_meter_data(data: Dict[str, Any]) -> Dict[str, Any]: 646 | _LOGGER.debug("Converting system meter data: '{}'".format(data)) 647 | 648 | sensor: Dict[str, Dict[str, Dict[str, Any]]] = {"meters": {}} 649 | 650 | for device_id, device_data in data.items(): 651 | sensor["meters"][device_id] = Fronius._device_meter_data(device_data) 652 | 653 | return sensor 654 | 655 | @staticmethod 656 | def _system_inverter_data(data: Dict[str, Any]) -> Dict[str, Any]: 657 | _LOGGER.debug("Converting system inverter data: '{}'".format(data)) 658 | sensor: Dict[str, Dict[str, Any]] = {} 659 | 660 | sensor["energy_day"] = {"value": 0, "unit": WATT_HOUR} 661 | sensor["energy_total"] = {"value": 0, "unit": WATT_HOUR} 662 | sensor["energy_year"] = {"value": 0, "unit": WATT_HOUR} 663 | sensor["power_ac"] = {"value": 0, "unit": WATT} 664 | 665 | sensor["inverters"] = {} 666 | 667 | if "DAY_ENERGY" in data: 668 | for i in data["DAY_ENERGY"]["Values"]: 669 | sensor["inverters"][i] = {} 670 | value = data["DAY_ENERGY"]["Values"][i] 671 | sensor["inverters"][i]["energy_day"] = { 672 | "value": value, 673 | "unit": data["DAY_ENERGY"]["Unit"], 674 | } 675 | sensor["energy_day"]["value"] += value or 0 676 | if "TOTAL_ENERGY" in data: 677 | for i in data["TOTAL_ENERGY"]["Values"]: 678 | value = data["TOTAL_ENERGY"]["Values"][i] 679 | sensor["inverters"][i]["energy_total"] = { 680 | "value": value, 681 | "unit": data["TOTAL_ENERGY"]["Unit"], 682 | } 683 | sensor["energy_total"]["value"] += value or 0 684 | if "YEAR_ENERGY" in data: 685 | for i in data["YEAR_ENERGY"]["Values"]: 686 | value = data["YEAR_ENERGY"]["Values"][i] 687 | sensor["inverters"][i]["energy_year"] = { 688 | "value": value, 689 | "unit": data["YEAR_ENERGY"]["Unit"], 690 | } 691 | sensor["energy_year"]["value"] += value or 0 692 | if "PAC" in data: 693 | for i in data["PAC"]["Values"]: 694 | value = data["PAC"]["Values"][i] 695 | sensor["inverters"][i]["power_ac"] = { 696 | "value": value, 697 | "unit": data["PAC"]["Unit"], 698 | } 699 | sensor["power_ac"]["value"] += value or 0 700 | 701 | return sensor 702 | 703 | @staticmethod 704 | def _device_ohmpilot_data(data: Dict[str, Any]) -> Dict[str, Any]: 705 | _LOGGER.debug("Converting ohmpilot data from '{}'".format(data)) 706 | device = {} 707 | 708 | if "CodeOfError" in data: 709 | device["error_code"] = {"value": data["CodeOfError"]} 710 | 711 | if "CodeOfState" in data: 712 | state_code = data["CodeOfState"] 713 | device["state_code"] = {"value": state_code} 714 | device["state_message"] = { 715 | "value": OHMPILOT_STATE_CODES.get(state_code, "Unknown") 716 | } 717 | 718 | if "Details" in data: 719 | device["hardware"] = {"value": data["Details"]["Hardware"]} 720 | device["manufacturer"] = {"value": data["Details"]["Manufacturer"]} 721 | device["model"] = {"value": data["Details"]["Model"]} 722 | device["serial"] = {"value": data["Details"]["Serial"]} 723 | device["software"] = {"value": data["Details"]["Software"]} 724 | 725 | if "EnergyReal_WAC_Sum_Consumed" in data: 726 | device["energy_real_ac_consumed"] = { 727 | "value": data["EnergyReal_WAC_Sum_Consumed"], 728 | "unit": WATT_HOUR, 729 | } 730 | 731 | if "PowerReal_PAC_Sum" in data: 732 | device["power_real_ac"] = {"value": data["PowerReal_PAC_Sum"], "unit": WATT} 733 | 734 | if "Temperature_Channel_1" in data: 735 | device["temperature_channel_1"] = { 736 | "value": data["Temperature_Channel_1"], 737 | "unit": DEGREE_CELSIUS, 738 | } 739 | 740 | return device 741 | 742 | @staticmethod 743 | def _system_ohmpilot_data(data: Dict[str, Any]) -> Dict[str, Any]: 744 | _LOGGER.debug("Converting system ohmpilot data: '{}'".format(data)) 745 | sensor: Dict[str, Dict[str, Dict[str, Any]]] = {"ohmpilots": {}} 746 | 747 | for device_id, device_data in data.items(): 748 | sensor["ohmpilots"][device_id] = Fronius._device_ohmpilot_data(device_data) 749 | 750 | return sensor 751 | 752 | @staticmethod 753 | def _device_meter_data(data: Dict[str, Any]) -> Dict[str, Any]: 754 | _LOGGER.debug("Converting meter data: '{}'".format(data)) 755 | 756 | meter = {} 757 | 758 | if "Current_AC_Phase_1" in data: 759 | meter["current_ac_phase_1"] = { 760 | "value": data["Current_AC_Phase_1"], 761 | "unit": AMPERE, 762 | } 763 | if "ACBRIDGE_CURRENT_ACTIVE_MEAN_01_F32" in data: 764 | meter["current_ac_phase_1"] = { 765 | "value": data["ACBRIDGE_CURRENT_ACTIVE_MEAN_01_F32"], 766 | "unit": AMPERE, 767 | } 768 | if "Current_AC_Phase_2" in data: 769 | meter["current_ac_phase_2"] = { 770 | "value": data["Current_AC_Phase_2"], 771 | "unit": AMPERE, 772 | } 773 | if "ACBRIDGE_CURRENT_ACTIVE_MEAN_02_F32" in data: 774 | meter["current_ac_phase_2"] = { 775 | "value": data["ACBRIDGE_CURRENT_ACTIVE_MEAN_02_F32"], 776 | "unit": AMPERE, 777 | } 778 | if "Current_AC_Phase_3" in data: 779 | meter["current_ac_phase_3"] = { 780 | "value": data["Current_AC_Phase_3"], 781 | "unit": AMPERE, 782 | } 783 | if "ACBRIDGE_CURRENT_ACTIVE_MEAN_03_F32" in data: 784 | meter["current_ac_phase_3"] = { 785 | "value": data["ACBRIDGE_CURRENT_ACTIVE_MEAN_03_F32"], 786 | "unit": AMPERE, 787 | } 788 | if "EnergyReactive_VArAC_Sum_Consumed" in data: 789 | meter["energy_reactive_ac_consumed"] = { 790 | "value": data["EnergyReactive_VArAC_Sum_Consumed"], 791 | "unit": VOLTAMPEREREACTIVE_HOUR, 792 | } 793 | if "EnergyReactive_VArAC_Sum_Produced" in data: 794 | meter["energy_reactive_ac_produced"] = { 795 | "value": data["EnergyReactive_VArAC_Sum_Produced"], 796 | "unit": VOLTAMPEREREACTIVE_HOUR, 797 | } 798 | if "EnergyReal_WAC_Minus_Absolute" in data: 799 | meter["energy_real_ac_minus"] = { 800 | "value": data["EnergyReal_WAC_Minus_Absolute"], 801 | "unit": WATT_HOUR, 802 | } 803 | if "EnergyReal_WAC_Plus_Absolute" in data: 804 | meter["energy_real_ac_plus"] = { 805 | "value": data["EnergyReal_WAC_Plus_Absolute"], 806 | "unit": WATT_HOUR, 807 | } 808 | if "EnergyReal_WAC_Sum_Consumed" in data: 809 | meter["energy_real_consumed"] = { 810 | "value": data["EnergyReal_WAC_Sum_Consumed"], 811 | "unit": WATT_HOUR, 812 | } 813 | if "SMARTMETER_ENERGYACTIVE_CONSUMED_SUM_F64" in data: 814 | meter["energy_real_consumed"] = { 815 | "value": data["SMARTMETER_ENERGYACTIVE_CONSUMED_SUM_F64"], 816 | "unit": WATT_HOUR, 817 | } 818 | if "EnergyReal_WAC_Sum_Produced" in data: 819 | meter["energy_real_produced"] = { 820 | "value": data["EnergyReal_WAC_Sum_Produced"], 821 | "unit": WATT_HOUR, 822 | } 823 | if "SMARTMETER_ENERGYACTIVE_PRODUCED_SUM_F64" in data: 824 | meter["energy_real_produced"] = { 825 | "value": data["SMARTMETER_ENERGYACTIVE_PRODUCED_SUM_F64"], 826 | "unit": WATT_HOUR, 827 | } 828 | if "Frequency_Phase_Average" in data: 829 | meter["frequency_phase_average"] = { 830 | "value": data["Frequency_Phase_Average"], 831 | "unit": HERTZ, 832 | } 833 | if "PowerApparent_S_Phase_1" in data: 834 | meter["power_apparent_phase_1"] = { 835 | "value": data["PowerApparent_S_Phase_1"], 836 | "unit": VOLTAMPERE, 837 | } 838 | if "PowerApparent_S_Phase_2" in data: 839 | meter["power_apparent_phase_2"] = { 840 | "value": data["PowerApparent_S_Phase_2"], 841 | "unit": VOLTAMPERE, 842 | } 843 | if "PowerApparent_S_Phase_3" in data: 844 | meter["power_apparent_phase_3"] = { 845 | "value": data["PowerApparent_S_Phase_3"], 846 | "unit": VOLTAMPERE, 847 | } 848 | if "PowerApparent_S_Sum" in data: 849 | meter["power_apparent"] = { 850 | "value": data["PowerApparent_S_Sum"], 851 | "unit": VOLTAMPERE, 852 | } 853 | if "PowerFactor_Phase_1" in data: 854 | meter["power_factor_phase_1"] = { 855 | "value": data["PowerFactor_Phase_1"], 856 | } 857 | if "PowerFactor_Phase_2" in data: 858 | meter["power_factor_phase_2"] = { 859 | "value": data["PowerFactor_Phase_2"], 860 | } 861 | if "PowerFactor_Phase_3" in data: 862 | meter["power_factor_phase_3"] = { 863 | "value": data["PowerFactor_Phase_3"], 864 | } 865 | if "PowerFactor_Sum" in data: 866 | meter["power_factor"] = {"value": data["PowerFactor_Sum"]} 867 | if "PowerReactive_Q_Phase_1" in data: 868 | meter["power_reactive_phase_1"] = { 869 | "value": data["PowerReactive_Q_Phase_1"], 870 | "unit": VOLTAMPEREREACTIVE, 871 | } 872 | if "PowerReactive_Q_Phase_2" in data: 873 | meter["power_reactive_phase_2"] = { 874 | "value": data["PowerReactive_Q_Phase_2"], 875 | "unit": VOLTAMPEREREACTIVE, 876 | } 877 | if "PowerReactive_Q_Phase_3" in data: 878 | meter["power_reactive_phase_3"] = { 879 | "value": data["PowerReactive_Q_Phase_3"], 880 | "unit": VOLTAMPEREREACTIVE, 881 | } 882 | if "PowerReactive_Q_Sum" in data: 883 | meter["power_reactive"] = { 884 | "value": data["PowerReactive_Q_Sum"], 885 | "unit": VOLTAMPEREREACTIVE, 886 | } 887 | if "PowerReal_P_Phase_1" in data: 888 | meter["power_real_phase_1"] = { 889 | "value": data["PowerReal_P_Phase_1"], 890 | "unit": WATT, 891 | } 892 | if "SMARTMETER_POWERACTIVE_01_F64" in data: 893 | meter["power_real_phase_1"] = { 894 | "value": data["SMARTMETER_POWERACTIVE_01_F64"], 895 | "unit": WATT, 896 | } 897 | if "PowerReal_P_Phase_2" in data: 898 | meter["power_real_phase_2"] = { 899 | "value": data["PowerReal_P_Phase_2"], 900 | "unit": WATT, 901 | } 902 | if "SMARTMETER_POWERACTIVE_02_F64" in data: 903 | meter["power_real_phase_2"] = { 904 | "value": data["SMARTMETER_POWERACTIVE_02_F64"], 905 | "unit": WATT, 906 | } 907 | if "PowerReal_P_Phase_3" in data: 908 | meter["power_real_phase_3"] = { 909 | "value": data["PowerReal_P_Phase_3"], 910 | "unit": WATT, 911 | } 912 | if "SMARTMETER_POWERACTIVE_03_F64" in data: 913 | meter["power_real_phase_3"] = { 914 | "value": data["SMARTMETER_POWERACTIVE_03_F64"], 915 | "unit": WATT, 916 | } 917 | if "PowerReal_P_Sum" in data: 918 | meter["power_real"] = {"value": data["PowerReal_P_Sum"], "unit": WATT} 919 | if "Voltage_AC_Phase_1" in data: 920 | meter["voltage_ac_phase_1"] = { 921 | "value": data["Voltage_AC_Phase_1"], 922 | "unit": VOLT, 923 | } 924 | if "Voltage_AC_Phase_2" in data: 925 | meter["voltage_ac_phase_2"] = { 926 | "value": data["Voltage_AC_Phase_2"], 927 | "unit": VOLT, 928 | } 929 | if "Voltage_AC_Phase_3" in data: 930 | meter["voltage_ac_phase_3"] = { 931 | "value": data["Voltage_AC_Phase_3"], 932 | "unit": VOLT, 933 | } 934 | if "Voltage_AC_PhaseToPhase_12" in data: 935 | meter["voltage_ac_phase_to_phase_12"] = { 936 | "value": data["Voltage_AC_PhaseToPhase_12"], 937 | "unit": VOLT, 938 | } 939 | if "Voltage_AC_PhaseToPhase_23" in data: 940 | meter["voltage_ac_phase_to_phase_23"] = { 941 | "value": data["Voltage_AC_PhaseToPhase_23"], 942 | "unit": VOLT, 943 | } 944 | if "Voltage_AC_PhaseToPhase_31" in data: 945 | meter["voltage_ac_phase_to_phase_31"] = { 946 | "value": data["Voltage_AC_PhaseToPhase_31"], 947 | "unit": VOLT, 948 | } 949 | if "Meter_Location_Current" in data: 950 | meter["meter_location"] = {"value": data["Meter_Location_Current"]} 951 | if "Enable" in data: 952 | meter["enable"] = {"value": data["Enable"]} 953 | if "Visible" in data: 954 | meter["visible"] = {"value": data["Visible"]} 955 | if "Details" in data: 956 | meter["manufacturer"] = {"value": data["Details"]["Manufacturer"]} 957 | meter["model"] = {"value": data["Details"]["Model"]} 958 | meter["serial"] = {"value": data["Details"]["Serial"]} 959 | 960 | return meter 961 | 962 | @staticmethod 963 | def _device_storage_data(data: Dict[str, Any]) -> Dict[str, Any]: 964 | _LOGGER.debug("Converting storage data from '{}'".format(data)) 965 | sensor = {} 966 | 967 | if "Controller" in data: 968 | controller = Fronius._controller_data(data["Controller"]) 969 | sensor.update(controller) 970 | 971 | if "Modules" in data: 972 | sensor["modules"] = {} 973 | module_count = 0 974 | 975 | for module in data["Modules"]: 976 | sensor["modules"][module_count] = Fronius._module_data(module) 977 | module_count += 1 978 | 979 | return sensor 980 | 981 | @staticmethod 982 | def _system_storage_data(data: Dict[str, Any]) -> Dict[str, Any]: 983 | _LOGGER.debug("Converting system storage data: '{}'".format(data)) 984 | 985 | sensor: Dict[str, Dict[str, Dict[str, Any]]] = {"storages": {}} 986 | 987 | for device_id, device_data in data.items(): 988 | sensor["storages"][device_id] = Fronius._device_storage_data(device_data) 989 | 990 | return sensor 991 | 992 | @staticmethod 993 | def _device_inverter_data(data: Dict[str, Any]) -> Dict[str, Any]: 994 | _LOGGER.debug("Converting inverter data from '{}'".format(data)) 995 | sensor = {} 996 | 997 | if "DAY_ENERGY" in data: 998 | sensor["energy_day"] = { 999 | "value": data["DAY_ENERGY"]["Value"], 1000 | "unit": data["DAY_ENERGY"]["Unit"], 1001 | } 1002 | if "TOTAL_ENERGY" in data: 1003 | sensor["energy_total"] = { 1004 | "value": data["TOTAL_ENERGY"]["Value"], 1005 | "unit": data["TOTAL_ENERGY"]["Unit"], 1006 | } 1007 | if "YEAR_ENERGY" in data: 1008 | sensor["energy_year"] = { 1009 | "value": data["YEAR_ENERGY"]["Value"], 1010 | "unit": data["YEAR_ENERGY"]["Unit"], 1011 | } 1012 | if "FAC" in data: 1013 | sensor["frequency_ac"] = { 1014 | "value": data["FAC"]["Value"], 1015 | "unit": data["FAC"]["Unit"], 1016 | } 1017 | if "IAC" in data: 1018 | sensor["current_ac"] = { 1019 | "value": data["IAC"]["Value"], 1020 | "unit": data["IAC"]["Unit"], 1021 | } 1022 | if "IDC" in data: 1023 | sensor["current_dc"] = { 1024 | "value": data["IDC"]["Value"], 1025 | "unit": data["IDC"]["Unit"], 1026 | } 1027 | for i in range(2, 10): 1028 | if f"IDC_{i}" in data: 1029 | sensor[f"current_dc_{i}"] = { 1030 | "value": data[f"IDC_{i}"]["Value"], 1031 | "unit": data[f"IDC_{i}"]["Unit"], 1032 | } 1033 | if "PAC" in data: 1034 | sensor["power_ac"] = { 1035 | "value": data["PAC"]["Value"], 1036 | "unit": data["PAC"]["Unit"], 1037 | } 1038 | if "UAC" in data: 1039 | sensor["voltage_ac"] = { 1040 | "value": data["UAC"]["Value"], 1041 | "unit": data["UAC"]["Unit"], 1042 | } 1043 | if "UDC" in data: 1044 | sensor["voltage_dc"] = { 1045 | "value": data["UDC"]["Value"], 1046 | "unit": data["UDC"]["Unit"], 1047 | } 1048 | for i in range(2, 10): 1049 | if f"UDC_{i}" in data: 1050 | sensor[f"voltage_dc_{i}"] = { 1051 | "value": data[f"UDC_{i}"]["Value"], 1052 | "unit": data[f"UDC_{i}"]["Unit"], 1053 | } 1054 | if "DeviceStatus" in data: 1055 | if "InverterState" in data["DeviceStatus"]: 1056 | sensor["inverter_state"] = { 1057 | "value": data["DeviceStatus"]["InverterState"] 1058 | } 1059 | if "ErrorCode" in data["DeviceStatus"]: 1060 | sensor["error_code"] = {"value": data["DeviceStatus"]["ErrorCode"]} 1061 | if "StatusCode" in data["DeviceStatus"]: 1062 | sensor["status_code"] = {"value": data["DeviceStatus"]["StatusCode"]} 1063 | if "LEDState" in data["DeviceStatus"]: 1064 | sensor["led_state"] = {"value": data["DeviceStatus"]["LEDState"]} 1065 | if "LEDColor" in data["DeviceStatus"]: 1066 | sensor["led_color"] = {"value": data["DeviceStatus"]["LEDColor"]} 1067 | 1068 | return sensor 1069 | 1070 | @staticmethod 1071 | def _device_inverter_3p_data(data): 1072 | _LOGGER.debug("Converting inverter 3p data from '{}'".format(data)) 1073 | sensor = {} 1074 | if "IAC_L1" in data: 1075 | sensor["current_ac_phase_1"] = { 1076 | "value": data["IAC_L1"]["Value"], 1077 | "unit": data["IAC_L1"]["Unit"], 1078 | } 1079 | if "IAC_L2" in data: 1080 | sensor["current_ac_phase_2"] = { 1081 | "value": data["IAC_L2"]["Value"], 1082 | "unit": data["IAC_L2"]["Unit"], 1083 | } 1084 | if "IAC_L3" in data: 1085 | sensor["current_ac_phase_3"] = { 1086 | "value": data["IAC_L3"]["Value"], 1087 | "unit": data["IAC_L3"]["Unit"], 1088 | } 1089 | if "UAC_L1" in data: 1090 | sensor["voltage_ac_phase_1"] = { 1091 | "value": data["UAC_L1"]["Value"], 1092 | "unit": data["UAC_L1"]["Unit"], 1093 | } 1094 | if "UAC_L2" in data: 1095 | sensor["voltage_ac_phase_2"] = { 1096 | "value": data["UAC_L2"]["Value"], 1097 | "unit": data["UAC_L2"]["Unit"], 1098 | } 1099 | if "UAC_L3" in data: 1100 | sensor["voltage_ac_phase_3"] = { 1101 | "value": data["UAC_L3"]["Value"], 1102 | "unit": data["UAC_L3"]["Unit"], 1103 | } 1104 | return sensor 1105 | 1106 | @staticmethod 1107 | def _controller_data(data: Dict[str, Any]) -> Dict[str, Any]: 1108 | controller = {} 1109 | 1110 | if "Capacity_Maximum" in data: 1111 | controller["capacity_maximum"] = { 1112 | "value": data["Capacity_Maximum"], 1113 | "unit": "Ah", 1114 | } 1115 | if "DesignedCapacity" in data: 1116 | controller["capacity_designed"] = { 1117 | "value": data["DesignedCapacity"], 1118 | "unit": "Ah", 1119 | } 1120 | if "Current_DC" in data: 1121 | controller["current_dc"] = {"value": data["Current_DC"], "unit": AMPERE} 1122 | if "Voltage_DC" in data: 1123 | controller["voltage_dc"] = {"value": data["Voltage_DC"], "unit": VOLT} 1124 | if "Voltage_DC_Maximum_Cell" in data: 1125 | controller["voltage_dc_maximum_cell"] = { 1126 | "value": data["Voltage_DC_Maximum_Cell"], 1127 | "unit": VOLT, 1128 | } 1129 | if "Voltage_DC_Minimum_Cell" in data: 1130 | controller["voltage_dc_minimum_cell"] = { 1131 | "value": data["Voltage_DC_Minimum_Cell"], 1132 | "unit": VOLT, 1133 | } 1134 | if "StateOfCharge_Relative" in data: 1135 | controller["state_of_charge"] = { 1136 | "value": data["StateOfCharge_Relative"], 1137 | "unit": PERCENT, 1138 | } 1139 | if "Temperature_Cell" in data: 1140 | controller["temperature_cell"] = { 1141 | "value": data["Temperature_Cell"], 1142 | "unit": DEGREE_CELSIUS, 1143 | } 1144 | if "Enable" in data: 1145 | controller["enable"] = {"value": data["Enable"]} 1146 | if "Details" in data: 1147 | controller["manufacturer"] = {"value": data["Details"]["Manufacturer"]} 1148 | controller["model"] = {"value": data["Details"]["Model"]} 1149 | controller["serial"] = {"value": data["Details"]["Serial"]} 1150 | 1151 | return controller 1152 | 1153 | @staticmethod 1154 | def _module_data(data: Dict[str, Any]) -> Dict[str, Any]: 1155 | module = {} 1156 | 1157 | if "Capacity_Maximum" in data: 1158 | module["capacity_maximum"] = { 1159 | "value": data["Capacity_Maximum"], 1160 | "unit": "Ah", 1161 | } 1162 | if "DesignedCapacity" in data: 1163 | module["capacity_designed"] = { 1164 | "value": data["DesignedCapacity"], 1165 | "unit": "Ah", 1166 | } 1167 | if "Current_DC" in data: 1168 | module["current_dc"] = {"value": data["Current_DC"], "unit": AMPERE} 1169 | if "Voltage_DC" in data: 1170 | module["voltage_dc"] = {"value": data["Voltage_DC"], "unit": VOLT} 1171 | if "Voltage_DC_Maximum_Cell" in data: 1172 | module["voltage_dc_maximum_cell"] = { 1173 | "value": data["Voltage_DC_Maximum_Cell"], 1174 | "unit": VOLT, 1175 | } 1176 | if "Voltage_DC_Minimum_Cell" in data: 1177 | module["voltage_dc_minimum_cell"] = { 1178 | "value": data["Voltage_DC_Minimum_Cell"], 1179 | "unit": VOLT, 1180 | } 1181 | if "StateOfCharge_Relative" in data: 1182 | module["state_of_charge"] = { 1183 | "value": data["StateOfCharge_Relative"], 1184 | "unit": PERCENT, 1185 | } 1186 | if "Temperature_Cell" in data: 1187 | module["temperature_cell"] = { 1188 | "value": data["Temperature_Cell"], 1189 | "unit": DEGREE_CELSIUS, 1190 | } 1191 | if "Temperature_Cell_Maximum" in data: 1192 | module["temperature_cell_maximum"] = { 1193 | "value": data["Temperature_Cell_Maximum"], 1194 | "unit": DEGREE_CELSIUS, 1195 | } 1196 | if "Temperature_Cell_Minimum" in data: 1197 | module["temperature_cell_minimum"] = { 1198 | "value": data["Temperature_Cell_Minimum"], 1199 | "unit": DEGREE_CELSIUS, 1200 | } 1201 | if "CycleCount_BatteryCell" in data: 1202 | module["cycle_count_cell"] = {"value": data["CycleCount_BatteryCell"]} 1203 | if "Status_BatteryCell" in data: 1204 | module["status_cell"] = {"value": data["Status_BatteryCell"]} 1205 | if "Enable" in data: 1206 | module["enable"] = {"value": data["Enable"]} 1207 | if "Details" in data: 1208 | module["manufacturer"] = {"value": data["Details"]["Manufacturer"]} 1209 | module["model"] = {"value": data["Details"]["Model"]} 1210 | module["serial"] = {"value": data["Details"]["Serial"]} 1211 | 1212 | return module 1213 | 1214 | @staticmethod 1215 | def _system_active_device_info(data: Dict[str, Any]) -> Dict[str, Any]: 1216 | _LOGGER.debug("Converting system active device data: '{}'".format(data)) 1217 | sensor = {} 1218 | 1219 | if "Inverter" in data: 1220 | inverters = [] 1221 | for device_id, device in data["Inverter"].items(): 1222 | inverter = {"device_id": device_id, "device_type": device["DT"]} 1223 | if "Serial" in device: 1224 | inverter["serial_number"] = device["Serial"] 1225 | inverters.append(inverter) 1226 | sensor["inverters"] = inverters 1227 | 1228 | if "Meter" in data: 1229 | meters = [] 1230 | for device_id, device in data["Meter"].items(): 1231 | meter = {"device_id": device_id} 1232 | if "Serial" in device: 1233 | meter["serial_number"] = device["Serial"] 1234 | meters.append(meter) 1235 | sensor["meters"] = meters 1236 | 1237 | if "Ohmpilot" in data: 1238 | ohmpilots = [] 1239 | for device_id, device in data["Ohmpilot"].items(): 1240 | ohmpilot = {"device_id": device_id} 1241 | if "Serial" in device: 1242 | ohmpilot["serial_number"] = device["Serial"] 1243 | ohmpilots.append(ohmpilot) 1244 | sensor["ohmpilots"] = ohmpilots 1245 | 1246 | if "SensorCard" in data: 1247 | sensor_cards = [] 1248 | for device_id, device in data["SensorCard"].items(): 1249 | sensor_card = {"device_id": device_id, "device_type": device["DT"]} 1250 | if "Serial" in device: 1251 | sensor_card["serial_number"] = device["Serial"] 1252 | sensor_card["channel_names"] = list( 1253 | map(lambda x: x.lower().replace(" ", "_"), device["ChannelNames"]) 1254 | ) 1255 | sensor_cards.append(sensor_card) 1256 | sensor["sensor_cards"] = sensor_cards 1257 | 1258 | if "Storage" in data: 1259 | storages = [] 1260 | for device_id, device in data["Storage"].items(): 1261 | storage = {"device_id": device_id} 1262 | if "Serial" in device: 1263 | storage["serial_number"] = device["Serial"] 1264 | storages.append(storage) 1265 | sensor["storages"] = storages 1266 | 1267 | if "StringControl" in data: 1268 | string_controls = [] 1269 | for device_id, device in data["StringControl"].items(): 1270 | string_control = {"device_id": device_id} 1271 | if "Serial" in device: 1272 | string_control["serial_number"] = device["Serial"] 1273 | string_controls.append(string_control) 1274 | sensor["string_controls"] = string_controls 1275 | 1276 | return sensor 1277 | 1278 | @staticmethod 1279 | def _inverter_info(data: Dict[str, Any]) -> Dict[str, List[Dict[str, Any]]]: 1280 | """Parse inverter info.""" 1281 | _LOGGER.debug("Converting inverter info: '{}'".format(data)) 1282 | inverters = [] 1283 | for inverter_index, inverter_info in data.items(): 1284 | inverter = { 1285 | "device_id": {"value": inverter_index}, 1286 | "device_type": {"value": inverter_info["DT"]}, 1287 | "pv_power": {"value": inverter_info["PVPower"], "unit": WATT}, 1288 | "status_code": {"value": inverter_info["StatusCode"]}, 1289 | "unique_id": {"value": inverter_info["UniqueID"]}, 1290 | } 1291 | if inverter_info["DT"] in INVERTER_DEVICE_TYPE: 1292 | # add manufacturer and model if known 1293 | inverter["device_type"].update( 1294 | INVERTER_DEVICE_TYPE[inverter_info["DT"]] 1295 | ) 1296 | # "CustomName" not available on API V0 so default to "" 1297 | # html escaped by V1 Snap-In, UTF-8 by V1 Gen24 1298 | if "CustomName" in inverter_info: 1299 | inverter["custom_name"] = { 1300 | "value": unescape(inverter_info["CustomName"]) 1301 | } 1302 | # "ErrorCode" not in V1-Gen24 1303 | if "ErrorCode" in inverter_info: 1304 | inverter["error_code"] = {"value": inverter_info["ErrorCode"]} 1305 | # "Show" not in V0 1306 | if "Show" in inverter_info: 1307 | inverter["show"] = {"value": inverter_info["Show"]} 1308 | inverters.append(inverter) 1309 | return {"inverters": inverters} 1310 | 1311 | @staticmethod 1312 | def _logger_info(data: Dict[str, Any]) -> Dict[str, Any]: 1313 | _LOGGER.debug("Converting Logger info: '{}'".format(data)) 1314 | sensor = {} 1315 | 1316 | if "CO2Factor" in data and "CO2Unit" in data: 1317 | co2_unit = unescape(data["CO2Unit"]) 1318 | sensor["co2_factor"] = { 1319 | "value": data["CO2Factor"], 1320 | "unit": f"{co2_unit}/kWh", 1321 | } 1322 | 1323 | if "CashCurrency" in data: 1324 | cash_currency = unescape(data["CashCurrency"]) 1325 | if "CashFactor" in data: 1326 | sensor["cash_factor"] = { 1327 | "value": data["CashFactor"], 1328 | "unit": f"{cash_currency}/kWh", 1329 | } 1330 | if "DeliveryFactor" in data: 1331 | sensor["delivery_factor"] = { 1332 | "value": data["DeliveryFactor"], 1333 | "unit": f"{cash_currency}/kWh", 1334 | } 1335 | 1336 | if "HWVersion" in data: 1337 | sensor["hardware_version"] = {"value": data["HWVersion"]} 1338 | 1339 | if "SWVersion" in data: 1340 | sensor["software_version"] = {"value": data["SWVersion"]} 1341 | 1342 | if "PlatformID" in data: 1343 | sensor["hardware_platform"] = {"value": data["PlatformID"]} 1344 | 1345 | if "ProductID" in data: 1346 | sensor["product_type"] = {"value": data["ProductID"]} 1347 | 1348 | if "TimezoneLocation" in data: 1349 | sensor["time_zone_location"] = {"value": data["TimezoneLocation"]} 1350 | 1351 | if "TimezoneName" in data: 1352 | sensor["time_zone"] = {"value": data["TimezoneName"]} 1353 | 1354 | if "UTCOffset" in data: 1355 | sensor["utc_offset"] = {"value": data["UTCOffset"]} 1356 | 1357 | if "UniqueID" in data: 1358 | sensor["unique_identifier"] = {"value": data["UniqueID"]} 1359 | 1360 | return sensor 1361 | --------------------------------------------------------------------------------