├── 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 | [](https://github.com/nielstron/pyfronius/actions/workflows/build.yml)
3 | [](https://coveralls.io/github/nielstron/pyfronius?branch=master)
4 | [](https://pypi.org/project/pyfronius/)
5 | 
6 | [](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 |
--------------------------------------------------------------------------------