From ce0732f689f68ece8e7c86ed08e49ff2e287f272 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Szil=C3=A1rd=20K=C3=A1losi?= Date: Mon, 12 Feb 2024 00:06:41 +0100 Subject: [PATCH 1/5] fix(x1_boost): totals are consecutive u16 ints (#129) * fix(x1_boost): totals are consecutive u16 ints * modify test cases from real world example * old test cases * formating * utils format * missing fixture --- solax/inverters/x1_boost.py | 8 +- tests/fixtures.py | 12 ++ tests/samples/expected_values.py | 19 +++ tests/samples/responses.py | 209 +++++++++++++++++++++++++++++++ 4 files changed, 244 insertions(+), 4 deletions(-) diff --git a/solax/inverters/x1_boost.py b/solax/inverters/x1_boost.py index 0ca8f44..58e92ab 100644 --- a/solax/inverters/x1_boost.py +++ b/solax/inverters/x1_boost.py @@ -3,7 +3,7 @@ from solax import utils from solax.inverter import Inverter, InverterHttpClient, Method, ResponseParser from solax.units import Total, Units -from solax.utils import div10, div100, to_signed +from solax.utils import div10, div100, pack_u16, to_signed class X1Boost(Inverter): @@ -46,12 +46,12 @@ def response_decoder(cls): "PV1 Power": (7, Units.W), "PV2 Power": (8, Units.W), "AC Frequency": (9, Units.HZ, div100), - "Total Generated Energy": (11, Total(Units.KWH), div10), + "Total Generated Energy": (pack_u16(11, 12), Total(Units.KWH), div10), "Today's Generated Energy": (13, Total(Units.KWH), div10), "Inverter Temperature": (39, Units.C), "Exported Power": (48, Units.W, to_signed), - "Total Export Energy": (50, Total(Units.KWH), div100), - "Total Import Energy": (52, Total(Units.KWH), div100), + "Total Export Energy": (pack_u16(50, 51), Total(Units.KWH), div100), + "Total Import Energy": (pack_u16(52, 53), Total(Units.KWH), div100), } @classmethod diff --git a/tests/fixtures.py b/tests/fixtures.py index 6df29c1..3983bfb 100644 --- a/tests/fixtures.py +++ b/tests/fixtures.py @@ -6,6 +6,7 @@ from tests.samples.expected_values import ( QVOLTHYBG33P_VALUES, X1_BOOST_VALUES, + X1_BOOST_VALUES_OVERFLOWN, X1_HYBRID_G4_VALUES, X1_MINI_VALUES, X1_MINI_VALUES_V34, @@ -25,6 +26,7 @@ QVOLTHYBG33P_RESPONSE_V34, X1_BOOST_AIR_MINI_RESPONSE, X1_BOOST_RESPONSE, + X1_BOOST_RESPONSE_OVERFLOWN, X1_HYBRID_G3_2X_MPPT_RESPONSE, X1_HYBRID_G3_RESPONSE, X1_HYBRID_G4_RESPONSE, @@ -130,6 +132,16 @@ def simple_http_fixture(httpserver): headers=X_FORWARDED_HEADER, data=None, ), + InverterUnderTest( + uri="/", + method="POST", + query_string="optType=ReadRealTimeData", + response=X1_BOOST_RESPONSE_OVERFLOWN, + inverter=inverter.X1Boost, + values=X1_BOOST_VALUES_OVERFLOWN, + headers=X_FORWARDED_HEADER, + data=None, + ), InverterUnderTest( uri="/", method="POST", diff --git a/tests/samples/expected_values.py b/tests/samples/expected_values.py index 849bff2..905b215 100644 --- a/tests/samples/expected_values.py +++ b/tests/samples/expected_values.py @@ -423,6 +423,25 @@ "Total Import Energy": 81.84, } +X1_BOOST_VALUES_OVERFLOWN = { + "AC Voltage": 237.4, + "AC Output Current": 6.7, + "AC Output Power": 1581, + "PV1 Voltage": 397.3, + "PV2 Voltage": 233.8, + "PV1 Current": 4.1, + "PV2 Current": 5.7, + "PV1 Power": 1628, + "PV2 Power": 1336, + "AC Frequency": 49.96, + "Total Generated Energy": 14055.6, + "Today's Generated Energy": 1.3, + "Inverter Temperature": 36, + "Exported Power": 1444, + "Total Export Energy": 11348.18, + "Total Import Energy": 1850.05, +} + QVOLTHYBG33P_VALUES = { "Network Voltage Phase 1": 221.4, "Network Voltage Phase 2": 223.8, diff --git a/tests/samples/responses.py b/tests/samples/responses.py index 04f9a93..64304a9 100644 --- a/tests/samples/responses.py +++ b/tests/samples/responses.py @@ -968,6 +968,215 @@ "Information": [5.0, 4, "XXXXXXX", 1, 2.21, 0.00, 1.38, 0.00, 0.00, 1], } +X1_BOOST_RESPONSE_OVERFLOWN = { + "sn": "XXXXXXX", + "ver": "2.034.06", + "type": 4, + "Data": [ + 2374, + 67, + 1581, + 3973, + 2338, + 41, + 57, + 1628, + 1336, + 4996, + 2, + 9484, + 2, + 13, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 36, + 0, + 10636, + 0, + 0, + 0, + 0, + 0, + 0, + 1444, + 0, + 20706, + 17, + 53933, + 2, + 0, + 39, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + ], + "Information": [3.680, 4, "XXXXXXX", 1, 2.06, 0, 1.27, 0, 0, 1], +} + X3_MIC_RESPONSE = { "type": "X3-MIC", "SN": "XXXXXXX", From 2c2040437e677f87e18d88399d7bb747967253d7 Mon Sep 17 00:00:00 2001 From: Vadim Kraus <38394456+VadimKraus@users.noreply.github.com> Date: Sun, 3 Mar 2024 21:17:36 +0100 Subject: [PATCH 2/5] raise validation error for all zero response data (#68) --- solax/response_parser.py | 30 ++++++++++++++++++++++++------ solax/utils.py | 17 ++++++++++++++++- tests/conftest.py | 1 + tests/fixtures.py | 25 +++++++++++++++++++++++++ tests/test_smoke.py | 15 +++++++++++++++ tests/test_vol.py | 22 +++++++++++++++++++++- 6 files changed, 102 insertions(+), 8 deletions(-) diff --git a/solax/response_parser.py b/solax/response_parser.py index 5d6cd85..3715ee0 100644 --- a/solax/response_parser.py +++ b/solax/response_parser.py @@ -8,13 +8,30 @@ from voluptuous.humanize import humanize_error from solax.units import SensorUnit -from solax.utils import PackerBuilderResult +from solax.utils import PackerBuilderResult, contains_none_zero_value _LOGGER = logging.getLogger(__name__) _LOGGER.setLevel(logging.INFO) InverterResponse = namedtuple("InverterResponse", "data, serial_number, version, type") +_KEY_DATA = "Data" +_KEY_SERIAL = "SN" +_KEY_VERSION = "version" +_KEY_VER = "ver" +_KEY_TYPE = "type" + + +GenericResponseSchema = vol.Schema( + { + vol.Required(_KEY_DATA): vol.Schema(contains_none_zero_value), + vol.Required(vol.Or(_KEY_SERIAL, _KEY_SERIAL.lower())): vol.All(), + vol.Required(vol.Or(_KEY_VERSION, _KEY_VER)): vol.All(), + vol.Required(_KEY_TYPE): vol.All(), + }, + extra=vol.REMOVE_EXTRA, +) + SensorIndexSpec = Union[int, PackerBuilderResult] ResponseDecoder = Dict[ str, @@ -27,7 +44,8 @@ class ResponseParser: def __init__(self, schema: vol.Schema, decoder: ResponseDecoder): - self.schema = schema + self.schema = vol.And(schema, GenericResponseSchema) + self.response_decoder = decoder def _decode_map(self) -> Dict[str, SensorIndexSpec]: @@ -82,8 +100,8 @@ def handle_response(self, resp: bytearray): _ = humanize_error(json_response, ex) raise return InverterResponse( - data=self.map_response(response["Data"]), - serial_number=response.get("SN", response.get("sn")), - version=response.get("ver", response.get("version")), - type=response["type"], + data=self.map_response(response[_KEY_DATA]), + serial_number=response.get(_KEY_SERIAL, response.get(_KEY_SERIAL.lower())), + version=response.get(_KEY_VER, response.get(_KEY_VERSION)), + type=response[_KEY_TYPE], ) diff --git a/solax/utils.py b/solax/utils.py index f2a884f..de38f04 100644 --- a/solax/utils.py +++ b/solax/utils.py @@ -1,4 +1,5 @@ -from typing import Protocol, Tuple +from numbers import Number +from typing import List, Protocol, Tuple from voluptuous import Invalid @@ -88,3 +89,17 @@ def twoway_div100(val): def to_url(host, port): return f"http://{host}:{port}/" + + +def contains_none_zero_value(value: List[Number]): + """Validate that at least one element is not zero. + Args: + value (List[Number]): list to validate + Raises: + Invalid: if all elements are zero + """ + + if isinstance(value, list): + if len(value) != 0 and any((v != 0 for v in value)): + return value + raise Invalid("All elements in the list {actual} are zero") diff --git a/tests/conftest.py b/tests/conftest.py index b256dfa..7057cae 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,5 +1,6 @@ # pylint: disable=unused-import from tests.fixtures import inverters_fixture # noqa: F401 +from tests.fixtures import inverters_fixture_all_zero # noqa: F401 from tests.fixtures import inverters_garbage_fixture # noqa: F401 from tests.fixtures import inverters_under_test # noqa: F401 from tests.fixtures import simple_http_fixture # noqa: F401 diff --git a/tests/fixtures.py b/tests/fixtures.py index 3983bfb..70592bc 100644 --- a/tests/fixtures.py +++ b/tests/fixtures.py @@ -1,4 +1,5 @@ from collections import namedtuple +from copy import copy import pytest @@ -294,3 +295,27 @@ def inverters_garbage_fixture(httpserver, request): query_string=request.param.query_string, ).respond_with_json({"bingo": "bango"}) yield ((httpserver.host, httpserver.port), request.param.inverter) + + +@pytest.fixture(params=INVERTERS_UNDER_TEST) +def inverters_fixture_all_zero(httpserver, request): + """Use defined responses but replace the data with all zero values. + Testing incorrect responses. + """ + + response = request.param.response + response = copy(response) + response["Data"] = [0] * (len(response["Data"])) + + httpserver.expect_request( + uri=request.param.uri, + method=request.param.method, + query_string=request.param.query_string, + headers=request.param.headers, + data=request.param.data, + ).respond_with_json(response) + yield ( + (httpserver.host, httpserver.port), + request.param.inverter, + request.param.values, + ) diff --git a/tests/test_smoke.py b/tests/test_smoke.py index a3b3c0e..d26b00f 100644 --- a/tests/test_smoke.py +++ b/tests/test_smoke.py @@ -66,3 +66,18 @@ def test_inverter_sensors_define_valid_units(inverters_under_test): f"is not a proper Unit on sensor '{name}' of Inverter '{inverters_under_test}'" ) assert isinstance(unit, Measurement), msg + + +@pytest.mark.asyncio +async def test_smoke_zero(inverters_fixture_all_zero): + """Responses with all zero values should be treated as an error. + Args: + inverters_fixture_all_zero (_type_): all responses with zero value data + """ + conn, inverter_class, _ = inverters_fixture_all_zero + + # msg = 'all zero values should be discarded' + with pytest.raises(InverterError): + inv = await build_right_variant(inverter_class, conn) + rt_api = solax.RealTimeAPI(inv) + await rt_api.get_data() diff --git a/tests/test_vol.py b/tests/test_vol.py index 3d0d380..40231dc 100644 --- a/tests/test_vol.py +++ b/tests/test_vol.py @@ -1,7 +1,7 @@ import pytest from voluptuous import Invalid -from solax.utils import startswith +from solax.utils import contains_none_zero_value, startswith def test_does_start_with(): @@ -23,3 +23,23 @@ def test_is_not_str(): actual = 1 with pytest.raises(Invalid): startswith(expected)(actual) + + +def test_contains_none_zero_value(): + with pytest.raises(Invalid): + contains_none_zero_value([0]) + + with pytest.raises(Invalid): + contains_none_zero_value([0, 0]) + + not_a_list = 1 + with pytest.raises(Invalid): + contains_none_zero_value(not_a_list) + + expected = [1, 0] + actual = contains_none_zero_value(expected) + assert actual == expected + + expected = [-1, 1] + actual = contains_none_zero_value(expected) + assert actual == expected From 878bf5be69e8a6ee77219be83eb7235b4c2a61e7 Mon Sep 17 00:00:00 2001 From: Robin Wohlers-Reichel Date: Mon, 4 Mar 2024 22:02:15 +1100 Subject: [PATCH 3/5] fix: update actions to latest major version (#141) * update actions to latest major version Signed-off-by: Robin Wohlers-Reichel * Revert "raise validation error for all zero response data (#68)" This reverts commit 2c2040437e677f87e18d88399d7bb747967253d7. --------- Signed-off-by: Robin Wohlers-Reichel --- .github/workflows/tests.yaml | 4 ++-- solax/response_parser.py | 30 ++++++------------------------ solax/utils.py | 17 +---------------- tests/conftest.py | 1 - tests/fixtures.py | 25 ------------------------- tests/test_smoke.py | 15 --------------- tests/test_vol.py | 22 +--------------------- 7 files changed, 10 insertions(+), 104 deletions(-) diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index f35651d..89aada1 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -11,9 +11,9 @@ jobs: python-version: [3.8, 3.9, '3.10'] steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v2 + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - name: Install dependencies diff --git a/solax/response_parser.py b/solax/response_parser.py index 3715ee0..5d6cd85 100644 --- a/solax/response_parser.py +++ b/solax/response_parser.py @@ -8,30 +8,13 @@ from voluptuous.humanize import humanize_error from solax.units import SensorUnit -from solax.utils import PackerBuilderResult, contains_none_zero_value +from solax.utils import PackerBuilderResult _LOGGER = logging.getLogger(__name__) _LOGGER.setLevel(logging.INFO) InverterResponse = namedtuple("InverterResponse", "data, serial_number, version, type") -_KEY_DATA = "Data" -_KEY_SERIAL = "SN" -_KEY_VERSION = "version" -_KEY_VER = "ver" -_KEY_TYPE = "type" - - -GenericResponseSchema = vol.Schema( - { - vol.Required(_KEY_DATA): vol.Schema(contains_none_zero_value), - vol.Required(vol.Or(_KEY_SERIAL, _KEY_SERIAL.lower())): vol.All(), - vol.Required(vol.Or(_KEY_VERSION, _KEY_VER)): vol.All(), - vol.Required(_KEY_TYPE): vol.All(), - }, - extra=vol.REMOVE_EXTRA, -) - SensorIndexSpec = Union[int, PackerBuilderResult] ResponseDecoder = Dict[ str, @@ -44,8 +27,7 @@ class ResponseParser: def __init__(self, schema: vol.Schema, decoder: ResponseDecoder): - self.schema = vol.And(schema, GenericResponseSchema) - + self.schema = schema self.response_decoder = decoder def _decode_map(self) -> Dict[str, SensorIndexSpec]: @@ -100,8 +82,8 @@ def handle_response(self, resp: bytearray): _ = humanize_error(json_response, ex) raise return InverterResponse( - data=self.map_response(response[_KEY_DATA]), - serial_number=response.get(_KEY_SERIAL, response.get(_KEY_SERIAL.lower())), - version=response.get(_KEY_VER, response.get(_KEY_VERSION)), - type=response[_KEY_TYPE], + data=self.map_response(response["Data"]), + serial_number=response.get("SN", response.get("sn")), + version=response.get("ver", response.get("version")), + type=response["type"], ) diff --git a/solax/utils.py b/solax/utils.py index de38f04..f2a884f 100644 --- a/solax/utils.py +++ b/solax/utils.py @@ -1,5 +1,4 @@ -from numbers import Number -from typing import List, Protocol, Tuple +from typing import Protocol, Tuple from voluptuous import Invalid @@ -89,17 +88,3 @@ def twoway_div100(val): def to_url(host, port): return f"http://{host}:{port}/" - - -def contains_none_zero_value(value: List[Number]): - """Validate that at least one element is not zero. - Args: - value (List[Number]): list to validate - Raises: - Invalid: if all elements are zero - """ - - if isinstance(value, list): - if len(value) != 0 and any((v != 0 for v in value)): - return value - raise Invalid("All elements in the list {actual} are zero") diff --git a/tests/conftest.py b/tests/conftest.py index 7057cae..b256dfa 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,6 +1,5 @@ # pylint: disable=unused-import from tests.fixtures import inverters_fixture # noqa: F401 -from tests.fixtures import inverters_fixture_all_zero # noqa: F401 from tests.fixtures import inverters_garbage_fixture # noqa: F401 from tests.fixtures import inverters_under_test # noqa: F401 from tests.fixtures import simple_http_fixture # noqa: F401 diff --git a/tests/fixtures.py b/tests/fixtures.py index 70592bc..3983bfb 100644 --- a/tests/fixtures.py +++ b/tests/fixtures.py @@ -1,5 +1,4 @@ from collections import namedtuple -from copy import copy import pytest @@ -295,27 +294,3 @@ def inverters_garbage_fixture(httpserver, request): query_string=request.param.query_string, ).respond_with_json({"bingo": "bango"}) yield ((httpserver.host, httpserver.port), request.param.inverter) - - -@pytest.fixture(params=INVERTERS_UNDER_TEST) -def inverters_fixture_all_zero(httpserver, request): - """Use defined responses but replace the data with all zero values. - Testing incorrect responses. - """ - - response = request.param.response - response = copy(response) - response["Data"] = [0] * (len(response["Data"])) - - httpserver.expect_request( - uri=request.param.uri, - method=request.param.method, - query_string=request.param.query_string, - headers=request.param.headers, - data=request.param.data, - ).respond_with_json(response) - yield ( - (httpserver.host, httpserver.port), - request.param.inverter, - request.param.values, - ) diff --git a/tests/test_smoke.py b/tests/test_smoke.py index d26b00f..a3b3c0e 100644 --- a/tests/test_smoke.py +++ b/tests/test_smoke.py @@ -66,18 +66,3 @@ def test_inverter_sensors_define_valid_units(inverters_under_test): f"is not a proper Unit on sensor '{name}' of Inverter '{inverters_under_test}'" ) assert isinstance(unit, Measurement), msg - - -@pytest.mark.asyncio -async def test_smoke_zero(inverters_fixture_all_zero): - """Responses with all zero values should be treated as an error. - Args: - inverters_fixture_all_zero (_type_): all responses with zero value data - """ - conn, inverter_class, _ = inverters_fixture_all_zero - - # msg = 'all zero values should be discarded' - with pytest.raises(InverterError): - inv = await build_right_variant(inverter_class, conn) - rt_api = solax.RealTimeAPI(inv) - await rt_api.get_data() diff --git a/tests/test_vol.py b/tests/test_vol.py index 40231dc..3d0d380 100644 --- a/tests/test_vol.py +++ b/tests/test_vol.py @@ -1,7 +1,7 @@ import pytest from voluptuous import Invalid -from solax.utils import contains_none_zero_value, startswith +from solax.utils import startswith def test_does_start_with(): @@ -23,23 +23,3 @@ def test_is_not_str(): actual = 1 with pytest.raises(Invalid): startswith(expected)(actual) - - -def test_contains_none_zero_value(): - with pytest.raises(Invalid): - contains_none_zero_value([0]) - - with pytest.raises(Invalid): - contains_none_zero_value([0, 0]) - - not_a_list = 1 - with pytest.raises(Invalid): - contains_none_zero_value(not_a_list) - - expected = [1, 0] - actual = contains_none_zero_value(expected) - assert actual == expected - - expected = [-1, 1] - actual = contains_none_zero_value(expected) - assert actual == expected From 2e2cdd2ddc2f9ef24ee3217f5226403ccbf82292 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20St=C3=A4ubert?= Date: Thu, 7 Mar 2024 22:41:11 +0100 Subject: [PATCH 4/5] X3 Hybrid G4: Attributes added (#136) * Update x3_hybrid_g4.py add Battery mode * Update x3_hybrid_g4.py Battery Remaining Energy added * Update x3_hybrid_g4.py Feed-in und Consumed Energy total added * Update x3_hybrid_g4.py EPS Energy total / today added * Update x3_hybrid_g4.py PV Energy total added * Update x3_hybrid_g4.py Battery (Dis-)charge Energy total/today added * Update x3_hybrid_g4.py Load/Generator Power added * Update x3_hybrid_g4.py EPS Current and Voltage Units fixed * Update x3_hybrid_g4.py Battery Voltage and Current added * Update x3_hybrid_g4.py import twoway_div100 * Update x3_hybrid_g4.py rename Feed-in -> Grid Power * using black for formatting * applied formatting with black v24.2.0 (debian testing) * commented duplicate 'Battery Voltage' in field 39. This is twice in the data. Field 169, 170 probably better than 39. * Update x3_hybrid_g4.py refactor battery_modes to _decode_battery_mode * Update x3_hybrid_g4.py using the right comment method (hopefully) * Update expected_values.py added test values for X3_HYBRID_G4 * Update expected_values.py added missing comma * Update expected_values.py removed duplicate value * Update expected_values.py added test value 'PV Energy total' * Update expected_values.py adjust expectred values with example response --- solax/inverters/x3_hybrid_g4.py | 53 +++++++++++++++++++++++++++----- tests/samples/expected_values.py | 16 +++++++++- 2 files changed, 60 insertions(+), 9 deletions(-) diff --git a/solax/inverters/x3_hybrid_g4.py b/solax/inverters/x3_hybrid_g4.py index ebe2cf7..34048da 100644 --- a/solax/inverters/x3_hybrid_g4.py +++ b/solax/inverters/x3_hybrid_g4.py @@ -2,7 +2,15 @@ from solax.inverter import Inverter from solax.units import Total, Units -from solax.utils import div10, div100, pack_u16, to_signed, to_signed32, twoway_div10 +from solax.utils import ( + div10, + div100, + pack_u16, + to_signed, + to_signed32, + twoway_div10, + twoway_div100, +) class X3HybridG4(Inverter): @@ -47,6 +55,15 @@ def _decode_run_mode(cls, run_mode): 10: "Standby", }.get(run_mode) + @classmethod + def _decode_battery_mode(cls, battery_mode): + return { + 0: "Self Use Mode", + 1: "Force Time Use", + 2: "Back Up Mode", + 3: "Feed-in Priority", + }.get(battery_mode) + @classmethod def response_decoder(cls): return { @@ -70,24 +87,44 @@ def response_decoder(cls): "Grid 3 Frequency": (18, Units.HZ, div100), "Run mode": (19, Units.NONE), "Run mode text": (19, Units.NONE, X3HybridG4._decode_run_mode), - "EPS 1 Voltage": (23, Units.W, div10), - "EPS 2 Voltage": (24, Units.W, div10), - "EPS 3 Voltage": (25, Units.W, div10), - "EPS 1 Current": (26, Units.W, twoway_div10), - "EPS 2 Current": (27, Units.W, twoway_div10), - "EPS 3 Current": (28, Units.W, twoway_div10), + "EPS 1 Voltage": (23, Units.V, div10), + "EPS 2 Voltage": (24, Units.V, div10), + "EPS 3 Voltage": (25, Units.V, div10), + "EPS 1 Current": (26, Units.A, twoway_div10), + "EPS 2 Current": (27, Units.A, twoway_div10), + "EPS 3 Current": (28, Units.A, twoway_div10), "EPS 1 Power": (29, Units.W, to_signed), "EPS 2 Power": (30, Units.W, to_signed), "EPS 3 Power": (31, Units.W, to_signed), - "Feed-in Power ": (pack_u16(34, 35), Units.W, to_signed32), + "Grid Power ": (pack_u16(34, 35), Units.W, to_signed32), + # 'Battery Voltage' is twice in the json response and covered with 169, 170 below. + # "Battery Voltage": (39, Units.V, div100), + "Battery Current": (40, Units.A, twoway_div100), "Battery Power": (41, Units.W, to_signed), + "Load/Generator Power": (47, Units.W, to_signed), "Radiator Temperature": (54, Units.C, to_signed), "Yield total": (pack_u16(68, 69), Total(Units.KWH), div10), "Yield today": (70, Units.KWH, div10), + "Battery Discharge Energy total": ( + pack_u16(74, 75), + Total(Units.KWH), + div10, + ), + "Battery Charge Energy total": (pack_u16(76, 77), Total(Units.KWH), div10), + "Battery Discharge Energy today": (78, Units.KWH, div10), + "Battery Charge Energy today": (79, Units.KWH, div10), + "PV Energy total": (pack_u16(80, 81), Total(Units.KWH), div10), + "EPS Energy total": (pack_u16(83, 84), Total(Units.KWH), div10), + "EPS Energy today": (85, Units.KWH, div10), "Feed-in Energy": (pack_u16(86, 87), Total(Units.KWH), div100), "Consumed Energy": (pack_u16(88, 89), Total(Units.KWH), div100), + "Feed-in Energy total": (pack_u16(90, 91), Total(Units.KWH), div100), + "Consumed Energy total": (pack_u16(92, 93), Total(Units.KWH), div100), "Battery Remaining Capacity": (103, Units.PERCENT), "Battery Temperature": (105, Units.C, to_signed), + "Battery Remaining Energy": (106, Units.KWH, div10), + "Battery mode": (168, Units.NONE), + "Battery mode text": (168, Units.NONE, X3HybridG4._decode_battery_mode), "Battery Voltage": (pack_u16(169, 170), Units.V, div100), } diff --git a/tests/samples/expected_values.py b/tests/samples/expected_values.py index 905b215..4af3422 100644 --- a/tests/samples/expected_values.py +++ b/tests/samples/expected_values.py @@ -255,15 +255,29 @@ "EPS 1 Power": 0.0, "EPS 2 Power": 0.0, "EPS 3 Power": 0.0, - "Feed-in Power ": -152.0, + "Grid Power ": -152.0, + "Battery Current": 0.0, "Battery Power": 0.0, + "Load/Generator Power": 342.0, "Radiator Temperature": 23.0, "Yield total": 4.4, "Yield today": 0.1, + "Battery Discharge Energy total": 2.4, + "Battery Charge Energy total": 1.0, + "Battery Discharge Energy today": 0.0, + "Battery Charge Energy today": 0.0, + "PV Energy total": 3.4, + "EPS Energy total": 0.0, + "EPS Energy today": 0.0, "Feed-in Energy": 0.0, "Consumed Energy": 20.09, + "Feed-in Energy total": 0.0, + "Consumed Energy total": 2.09, "Battery Remaining Capacity": 30.0, "Battery Temperature": 22.0, + "Battery Remaining Energy": 3.3, + "Battery mode": 0.0, + "Battery mode text": "Self Use Mode", "Battery Voltage": 234.6, } From a44017c6f12e647b37a9410b683e5b623728369d Mon Sep 17 00:00:00 2001 From: Dos Moonen Date: Sun, 31 Mar 2024 23:35:35 +0200 Subject: [PATCH 5/5] Refactor discovery (#145) * Coverage combine * Refactor discovery and stagger HTTP requests. * Add DailyTotal NamedTyple * Add inverter_serial_number to InverterResponse --- .github/workflows/publish.yml | 2 +- .github/workflows/tests.yaml | 33 +++- setup.py | 18 +++ solax/__init__.py | 2 +- solax/discovery.py | 232 ++++++++++++++++++----------- solax/inverter.py | 43 ++++-- solax/inverter_http_client.py | 101 +++++++++---- solax/inverters/qvolt_hyb_g3_3p.py | 45 +++--- solax/inverters/x1.py | 16 +- solax/inverters/x1_boost.py | 33 ++-- solax/inverters/x1_hybrid_gen4.py | 27 ++-- solax/inverters/x1_mini.py | 16 +- solax/inverters/x1_mini_v34.py | 14 +- solax/inverters/x1_smart.py | 33 ++-- solax/inverters/x3.py | 18 ++- solax/inverters/x3_hybrid_g4.py | 26 +++- solax/inverters/x3_mic_pro_g2.py | 14 +- solax/inverters/x3_v34.py | 32 ++-- solax/inverters/x_hybrid.py | 24 +-- solax/response_parser.py | 72 ++++++--- solax/units.py | 8 + tests/samples/responses.py | 18 +-- tests/test_base_inverter.py | 3 +- tests/test_discovery.py | 115 +++++++++++++- 24 files changed, 653 insertions(+), 292 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index dacad3e..c5b7a35 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -19,7 +19,7 @@ jobs: steps: - uses: actions/checkout@v1 - name: Set up Python - uses: actions/setup-python@v1 + uses: actions/setup-python@v5 with: python-version: '3.x' - name: Install dependencies diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index 89aada1..d6583f5 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -8,7 +8,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: [3.8, 3.9, '3.10'] + python-version: [3.8, 3.9, '3.10', '3.11', '3.12'] steps: - uses: actions/checkout@v4 @@ -19,7 +19,7 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip - python setup.py install + python -m pip install . pip install --upgrade flake8 pylint pytest pytest-cov pytest-asyncio pytest-httpserver black mypy isort - name: Check code style with black run: | @@ -38,4 +38,31 @@ jobs: pylint -d 'C0111' solax tests - name: Test with pytest run: | - pytest --cov=solax --cov-fail-under=100 --cov-branch --cov-report=term-missing . + pytest --cov=solax --cov-branch --cov-report=term-missing . + mv .coverage .coverage.${{ matrix.python-version }} + - name: Upload coverage + uses: actions/upload-artifact@v4 + with: + name: .coverage-${{ matrix.python-version }} + path: .coverage.${{ matrix.python-version }} + if-no-files-found: error + + coverage: + runs-on: ubuntu-latest + needs: [build] + steps: + - uses: actions/checkout@v4 + - name: Set up Python 3.12 + uses: actions/setup-python@v5 + with: + python-version: 3.12 + - name: Download coverage files + uses: actions/download-artifact@v4 + with: + merge-multiple: true + - name: Coverage combine + run: | + python -m pip install --upgrade pip + pip install --upgrade coverage + coverage combine + coverage report -m --fail-under=100 diff --git a/setup.py b/setup.py index 759b61e..515608c 100644 --- a/setup.py +++ b/setup.py @@ -18,6 +18,8 @@ "aiohttp>=3.5.4, <4", "async_timeout>=4.0.2", "voluptuous>=0.11.5", + "importlib_metadata>=3.6; python_version<'3.10'", + "typing_extensions>=4.1.0; python_version<'3.11'", ], setup_requires=[ "setuptools_scm", @@ -28,4 +30,20 @@ "Operating System :: OS Independent", ], python_requires=">=3.8", + entry_points={ + "solax.inverter": [ + "qvolt_hyb_g3_3p = solax.inverters.qvolt_hyb_g3_3p:QVOLTHYBG33P", + "x1 = solax.inverters.x1:X1", + "x1_boost = solax.inverters.x1_boost:X1Boost", + "x1_hybrid_gen4 = solax.inverters.x1_hybrid_gen4:X1HybridGen4", + "x1_mini = solax.inverters.x1_mini:X1Mini", + "x1_mini_v34 = solax.inverters.x1_mini_v34:X1MiniV34", + "x1_smart = solax.inverters.x1_smart:X1Smart", + "x3 = solax.inverters.x3:X3", + "x3_hybrid_g4 = solax.inverters.x3_hybrid_g4:X3HybridG4", + "x3_mic_pro_g2 = solax.inverters.x3_mic_pro_g2:X3MicProG2", + "x3_v34 = solax.inverters.x3_v34:X3V34", + "x_hybrid = solax.inverters.x_hybrid:XHybrid", + ], + }, ) diff --git a/solax/__init__.py b/solax/__init__.py index 6fd9817..1ac860c 100644 --- a/solax/__init__.py +++ b/solax/__init__.py @@ -33,7 +33,7 @@ async def rt_request(inv: Inverter, retry, t_wait=0) -> InverterResponse: async def real_time_api(ip_address, port=80, pwd=""): - i = await discover(ip_address, port, pwd) + i = await discover(ip_address, port, pwd, return_when=asyncio.FIRST_COMPLETED) return RealTimeAPI(i) diff --git a/solax/discovery.py b/solax/discovery.py index 9cfdd60..77d144f 100644 --- a/solax/discovery.py +++ b/solax/discovery.py @@ -1,103 +1,161 @@ import asyncio import logging -import typing - -from solax.inverter import Inverter, InverterError -from solax.inverters import ( - QVOLTHYBG33P, - X1, - X3, - X3V34, - X1Boost, - X1HybridGen4, - X1Mini, - X1MiniV34, - X1Smart, - X3HybridG4, - X3MicProG2, - XHybrid, -) +import sys +from asyncio import Future, Task +from collections import defaultdict +from typing import Dict, Literal, Optional, Sequence, Set, TypedDict, Union, cast -# registry of inverters -REGISTRY = [ - XHybrid, - X3, - X3V34, - X3HybridG4, - X1, - X1Mini, - X1MiniV34, - X1Smart, - QVOLTHYBG33P, - X1Boost, - X1HybridGen4, - X3MicProG2, -] +from async_timeout import timeout + +from solax.inverter import Inverter +from solax.inverter_http_client import InverterHttpClient + +__all__ = ("discover", "DiscoveryKeywords", "DiscoveryError") +if sys.version_info >= (3, 10): + from importlib.metadata import entry_points +else: + from importlib_metadata import entry_points + +if sys.version_info >= (3, 11): + from typing import Unpack +else: + from typing_extensions import Unpack + +# registry of inverters +REGISTRY = {ep.load() for ep in entry_points(group="solax.inverter")} logging.basicConfig(level=logging.INFO) -class DiscoveryState: - _discovered_inverter: typing.Optional[Inverter] - _tasks: typing.Set[asyncio.Task] - _failures: list - - def __init__(self): - self._discovered_inverter = None - self._tasks = set() - self._failures = [] - - def get_discovered_inverter(self): - return self._discovered_inverter - - def _task_handler(self, task): - try: - self._tasks.remove(task) - result = task.result() - self._discovered_inverter = result - for a_task in self._tasks: - a_task.cancel() - except asyncio.CancelledError: - logging.debug("task %s canceled", task.get_name()) - except InverterError as ex: - self._failures.append(ex) - - @classmethod - async def _discovery_task(cls, i) -> Inverter: - logging.info("Trying inverter %s", i) - await i.get_data() - return i - - async def discover(self, host, port, pwd="") -> Inverter: - for inverter in REGISTRY: - for i in inverter.build_all_variants(host, port, pwd): - task = asyncio.create_task(self._discovery_task(i), name=f"{i}") - task.add_done_callback(self._task_handler) - self._tasks.add(task) - - while len(self._tasks) > 0: - logging.debug("%d discovery tasks are still running...", len(self._tasks)) - await asyncio.sleep(0.5) - - if self._discovered_inverter is not None: - logging.info("Discovered inverter: %s", self._discovered_inverter) - return self._discovered_inverter - - msg = ( +class DiscoveryKeywords(TypedDict, total=False): + timeout: Optional[float] + inverters: Sequence[Inverter] + return_when: Union[Literal["ALL_COMPLETED"], Literal["FIRST_COMPLETED"]] + + +if sys.version_info >= (3, 9): + _InverterTask = Task[Inverter] +else: + _InverterTask = Task + + +class _DiscoveryHttpClient: + def __init__( + self, + inverter: Inverter, + http_client: InverterHttpClient, + request: Future, + ): + self._inverter = inverter + self._http_client = http_client + self._request: Future = request + + def __str__(self): + return str(self._http_client) + + async def request(self): + request = await self._request + request.add_done_callback(self._restore_http_client) + return await request + + def _restore_http_client(self, _: _InverterTask): + self._inverter.http_client = self._http_client + + +async def _discovery_task(i) -> Inverter: + logging.info("Trying inverter %s", i) + await i.get_data() + return i + + +async def discover( + host, port, pwd="", **kwargs: Unpack[DiscoveryKeywords] +) -> Union[Inverter, Set[Inverter]]: + async with timeout(kwargs.get("timeout", 15)): + done: Set[_InverterTask] = set() + pending: Set[_InverterTask] = set() + failures = set() + requests: Dict[InverterHttpClient, Future] = defaultdict( + asyncio.get_running_loop().create_future + ) + + return_when = kwargs.get("return_when", asyncio.FIRST_COMPLETED) + for cls in kwargs.get("inverters", REGISTRY): + for inverter in cls.build_all_variants(host, port, pwd): + inverter.http_client = cast( + InverterHttpClient, + _DiscoveryHttpClient( + inverter, inverter.http_client, requests[inverter.http_client] + ), + ) + + pending.add( + asyncio.create_task(_discovery_task(inverter), name=f"{inverter}") + ) + + if not pending: + raise DiscoveryError("No inverters to try to discover") + + def cancel(pending: Set[_InverterTask]) -> Set[_InverterTask]: + for task in pending: + task.cancel() + return pending + + def remove_failures_from(done: Set[_InverterTask]) -> None: + for task in set(done): + exc = task.exception() + if exc: + failures.add(exc) + done.remove(task) + + # stagger HTTP request to prevent accidental Denial Of Service + async def stagger() -> None: + for http_client, future in requests.items(): + future.set_result(asyncio.create_task(http_client.request())) + await asyncio.sleep(1) + + staggered = asyncio.create_task(stagger()) + + while pending and (not done or return_when != asyncio.FIRST_COMPLETED): + try: + done, pending = await asyncio.wait(pending, return_when=return_when) + except asyncio.CancelledError: + staggered.cancel() + await asyncio.gather( + staggered, *cancel(pending), return_exceptions=True + ) + raise + + remove_failures_from(done) + + if done and return_when == asyncio.FIRST_COMPLETED: + break + + logging.debug("%d discovery tasks are still running...", len(pending)) + + if pending and return_when != asyncio.FIRST_COMPLETED: + pending.update(done) + done.clear() + + remove_failures_from(done) + staggered.cancel() + await asyncio.gather(staggered, *cancel(pending), return_exceptions=True) + + if done: + logging.info("Discovered inverters: %s", {task.result() for task in done}) + if return_when == asyncio.FIRST_COMPLETED: + return await next(iter(done)) + + return {task.result() for task in done} + + raise DiscoveryError( "Unable to connect to the inverter at " f"host={host} port={port}, or your inverter is not supported yet.\n" "Please see https://github.com/squishykid/solax/wiki/DiscoveryError\n" - f"Failures={str(self._failures)}" + f"Failures={str(failures)}" ) - raise DiscoveryError(msg) class DiscoveryError(Exception): """Raised when unable to discover inverter""" - - -async def discover(host, port, pwd="") -> Inverter: - discover_state = DiscoveryState() - await discover_state.discover(host, port, pwd) - return discover_state.get_discovered_inverter() diff --git a/solax/inverter.py b/solax/inverter.py index 5304263..8abe642 100644 --- a/solax/inverter.py +++ b/solax/inverter.py @@ -1,4 +1,5 @@ -from typing import Dict, Tuple +from abc import abstractmethod +from typing import Any, Dict, Optional, Tuple import aiohttp import voluptuous as vol @@ -27,33 +28,38 @@ def response_decoder(cls) -> ResponseDecoder: # pylint: enable=C0301 _schema = vol.Schema({}) # type: vol.Schema - def __init__( - self, http_client: InverterHttpClient, response_parser: ResponseParser - ): + def __init__(self, http_client: InverterHttpClient): self.manufacturer = "Solax" - self.response_parser = response_parser self.http_client = http_client + schema = type(self).schema() + response_decoder = type(self).response_decoder() + dongle_serial_number_getter = type(self).dongle_serial_number_getter + inverter_serial_number_getter = type(self).inverter_serial_number_getter + self.response_parser = ResponseParser( + schema, + response_decoder, + dongle_serial_number_getter, + inverter_serial_number_getter, + ) + @classmethod def _build(cls, host, port, pwd="", params_in_query=True): url = utils.to_url(host, port) - http_client = InverterHttpClient(url, Method.POST, pwd) + http_client = InverterHttpClient(url=url, method=Method.POST, pwd=pwd) if params_in_query: - http_client.with_default_query() + http_client = http_client.with_default_query() else: - http_client.with_default_data() + http_client = http_client.with_default_data() - schema = cls.schema() - response_decoder = cls.response_decoder() - response_parser = ResponseParser(schema, response_decoder) - return cls(http_client, response_parser) + return cls(http_client) @classmethod def build_all_variants(cls, host, port, pwd=""): - versions = [ + versions = { cls._build(host, port, pwd, True), cls._build(host, port, pwd, False), - ] + } return versions async def get_data(self) -> InverterResponse: @@ -105,5 +111,14 @@ def schema(cls) -> vol.Schema: """ return cls._schema + @classmethod + def dongle_serial_number_getter(cls, response: Dict[str, Any]) -> Optional[str]: + return response["sn"] + + @classmethod + @abstractmethod + def inverter_serial_number_getter(cls, response: Dict[str, Any]) -> Optional[str]: + raise NotImplementedError # pragma: no cover + def __str__(self) -> str: return f"{self.__class__.__name__}::{self.http_client}" diff --git a/solax/inverter_http_client.py b/solax/inverter_http_client.py index cfea7f6..e68adce 100644 --- a/solax/inverter_http_client.py +++ b/solax/inverter_http_client.py @@ -1,47 +1,96 @@ +from __future__ import annotations + +import dataclasses +import sys +from dataclasses import dataclass, field from enum import Enum +from typing import Dict, Optional +from weakref import WeakValueDictionary import aiohttp +__all__ = ("InverterHttpClient", "Method") + +if sys.version_info >= (3, 10): + from dataclasses import KW_ONLY + +_CACHE: WeakValueDictionary[int, InverterHttpClient] = WeakValueDictionary() + class Method(Enum): GET = 1 POST = 2 +_kwargs: Dict[str, bool] = {} + +if sys.version_info >= (3, 11): + _kwargs["slots"] = True + _kwargs["weakref_slot"] = True + + +@dataclass(frozen=True, **_kwargs) class InverterHttpClient: - def __init__(self, url, method: Method = Method.POST, pwd=""): - """Initialize the Http client.""" - self.url = url - self.method = method - self.pwd = pwd - self.headers = None - self.data = None - self.query = "" - - @classmethod - def build_w_url(cls, url, method: Method = Method.POST): - http_client = cls(url, method, "") - return http_client - - def with_headers(self, headers): - self.headers = headers - return self - - def with_default_data(self): + """Initialize the Http client.""" + + if sys.version_info >= (3, 10): + _: KW_ONLY + + url: str + method: Method + pwd: str + headers: Dict[str, str] = field(default_factory=dict) + data: Optional[bytes] = None + query: str = "" + + def __hash__(self): + return id(self) + + def replace(self, **kwargs) -> InverterHttpClient: + fields = dataclasses.fields(InverterHttpClient) + data = {} + values = [] + + for fld in fields: + if fld.name in kwargs: + value = kwargs.pop(fld.name) + else: + value = getattr(self, fld.name) + + data[fld.name] = value + + if isinstance(value, dict): + value = dict(value) + values.append(tuple(value.items())) + else: + values.append(value) + + data[fld.name] = value + + key = hash(tuple(values)) + cached = _CACHE.get(key) + + if cached is None: + cached = _CACHE[key] = InverterHttpClient(**data) + + return cached + + def with_headers(self, headers) -> InverterHttpClient: + return self.replace(headers=dict(headers)) + + def with_default_data(self) -> InverterHttpClient: data = "optType=ReadRealTimeData" if self.pwd: data = data + "&pwd=" + self.pwd return self.with_data(data) - def with_data(self, data): - self.data = data - return self + def with_data(self, data) -> InverterHttpClient: + return self.replace(data=data) - def with_query(self, query): - self.query = query - return self + def with_query(self, query) -> InverterHttpClient: + return self.replace(query=query) - def with_default_query(self): + def with_default_query(self) -> InverterHttpClient: if self.pwd: base = "optType=ReadRealTimeData&pwd={}&" query = base.format(self.pwd) diff --git a/solax/inverters/qvolt_hyb_g3_3p.py b/solax/inverters/qvolt_hyb_g3_3p.py index f686d3e..5bda4ee 100644 --- a/solax/inverters/qvolt_hyb_g3_3p.py +++ b/solax/inverters/qvolt_hyb_g3_3p.py @@ -1,8 +1,9 @@ +from typing import Any, Dict, Optional + import voluptuous as vol -from solax import utils -from solax.inverter import Inverter, InverterHttpClient, Method, ResponseParser -from solax.units import Total, Units +from solax.inverter import Inverter, InverterHttpClient +from solax.units import DailyTotal, Measurement, Total, Units from solax.utils import div10, div100, pack_u16, to_signed, twoway_div10, twoway_div100 @@ -42,10 +43,8 @@ def battery_modes(value): 3: "Feed-in Priority", }.get(value, f"unmapped value '{value}'") - def __init__( - self, http_client: InverterHttpClient, response_parser: ResponseParser - ): - super().__init__(http_client, response_parser) + def __init__(self, http_client: InverterHttpClient, *args, **kwargs): + super().__init__(http_client, *args, **kwargs) self.manufacturer = "Qcells" _schema = vol.Schema( @@ -53,13 +52,13 @@ def __init__( vol.Required("type"): vol.All(int, 14), vol.Required("sn"): str, vol.Required("ver"): str, - vol.Required("Data"): vol.Schema( + vol.Required("data"): vol.Schema( vol.All( [vol.Coerce(float)], vol.Length(min=200, max=200), ) ), - vol.Required("Information"): vol.Schema( + vol.Required("information"): vol.Schema( vol.All(vol.Length(min=10, max=10)) ), }, @@ -125,22 +124,26 @@ def response_decoder(cls): Total(Units.KWH), div10, ), - "Today's Battery Discharge Energy": (78, Units.KWH, div10), - "Today's Battery Charge Energy": (79, Units.KWH, div10), + "Today's Battery Discharge Energy": (78, DailyTotal(Units.KWH), div10), + "Today's Battery Charge Energy": (79, DailyTotal(Units.KWH), div10), "Total PV Energy": (pack_u16(80, 81), Total(Units.KWH), div10), - "Today's Energy": (82, Units.KWH, div10), + "Today's Energy": (82, DailyTotal(Units.KWH), div10), # 83-85: always 0 "Total Feed-in Energy": (pack_u16(86, 87), Total(Units.KWH), div100), "Total Consumption": (pack_u16(88, 89), Total(Units.KWH), div100), - "Today's Feed-in Energy": (90, Units.KWH, div100), + "Today's Feed-in Energy": (90, DailyTotal(Units.KWH), div100), # 91: always 0 - "Today's Consumption": (92, Units.KWH, div100), + "Today's Consumption": (92, DailyTotal(Units.KWH), div100), # 93-101: always 0 # 102: always 1 "Battery Remaining Capacity": (103, Units.PERCENT), # 104: always 1 "Battery Temperature": (105, Units.C), - "Battery Remaining Energy": (106, Units.KWH, div10), + "Battery Remaining Energy": ( + 106, + Measurement(Units.KWH, storage=True), + div10, + ), # 107: always 256 or 0 # 108: always 3504 # 109: always 2400 @@ -161,16 +164,10 @@ def response_decoder(cls): } @classmethod - def _build(cls, host, port, pwd="", params_in_query=True): - url = utils.to_url(host, port) - http_client = InverterHttpClient(url, Method.POST, pwd).with_default_data() - - schema = cls._schema - response_decoder = cls.response_decoder() - response_parser = ResponseParser(schema, response_decoder) - return cls(http_client, response_parser) + def inverter_serial_number_getter(cls, response: Dict[str, Any]) -> Optional[str]: + return response["information"][2] @classmethod def build_all_variants(cls, host, port, pwd=""): - versions = [cls._build(host, port, pwd)] + versions = [cls._build(host, port, pwd, False)] return versions diff --git a/solax/inverters/x1.py b/solax/inverters/x1.py index 5abf23d..54121ba 100644 --- a/solax/inverters/x1.py +++ b/solax/inverters/x1.py @@ -1,7 +1,9 @@ +from typing import Any, Dict, Optional + import voluptuous as vol from solax.inverter import Inverter -from solax.units import Total, Units +from solax.units import DailyTotal, Total, Units from solax.utils import startswith @@ -10,9 +12,9 @@ class X1(Inverter): _schema = vol.Schema( { vol.Required("type"): vol.All(str, startswith("X1-")), - vol.Required("SN"): str, + vol.Required("sn"): str, vol.Required("ver"): str, - vol.Required("Data"): vol.Schema( + vol.Required("data"): vol.Schema( vol.All( [vol.Coerce(float)], vol.Any( @@ -22,7 +24,7 @@ class X1(Inverter): ), ) ), - vol.Required("Information"): vol.Schema(vol.All(vol.Length(min=9, max=9))), + vol.Required("information"): vol.Schema(vol.All(vol.Length(min=9, max=9))), }, extra=vol.REMOVE_EXTRA, ) @@ -38,7 +40,7 @@ def response_decoder(cls): "Network Voltage": (5, Units.V), "AC Power": (6, Units.W), "Inverter Temperature": (7, Units.C), - "Today's Energy": (8, Units.KWH), + "Today's Energy": (8, DailyTotal(Units.KWH)), "Total Energy": (9, Total(Units.KWH)), "Exported Power": (10, Units.W), "PV1 Power": (11, Units.W), @@ -59,3 +61,7 @@ def response_decoder(cls): } # pylint: enable=duplicate-code + + @classmethod + def inverter_serial_number_getter(cls, response: Dict[str, Any]) -> Optional[str]: + return response["information"][3] diff --git a/solax/inverters/x1_boost.py b/solax/inverters/x1_boost.py index 58e92ab..9ff4b11 100644 --- a/solax/inverters/x1_boost.py +++ b/solax/inverters/x1_boost.py @@ -1,8 +1,9 @@ +from typing import Any, Dict, Optional + import voluptuous as vol -from solax import utils -from solax.inverter import Inverter, InverterHttpClient, Method, ResponseParser -from solax.units import Total, Units +from solax.inverter import Inverter +from solax.units import DailyTotal, Total, Units from solax.utils import div10, div100, pack_u16, to_signed @@ -20,13 +21,13 @@ class X1Boost(Inverter): "sn", ): str, vol.Required("ver"): str, - vol.Required("Data"): vol.Schema( + vol.Required("data"): vol.Schema( vol.All( [vol.Coerce(float)], vol.Length(min=200, max=200), ) ), - vol.Required("Information"): vol.Schema( + vol.Required("information"): vol.Schema( vol.All(vol.Length(min=10, max=10)) ), }, @@ -47,7 +48,7 @@ def response_decoder(cls): "PV2 Power": (8, Units.W), "AC Frequency": (9, Units.HZ, div100), "Total Generated Energy": (pack_u16(11, 12), Total(Units.KWH), div10), - "Today's Generated Energy": (13, Total(Units.KWH), div10), + "Today's Generated Energy": (13, DailyTotal(Units.KWH), div10), "Inverter Temperature": (39, Units.C), "Exported Power": (48, Units.W, to_signed), "Total Export Energy": (pack_u16(50, 51), Total(Units.KWH), div100), @@ -55,20 +56,8 @@ def response_decoder(cls): } @classmethod - def _build(cls, host, port, pwd="", params_in_query=True): - url = utils.to_url(host, port) - http_client = InverterHttpClient(url, Method.POST, pwd) - if params_in_query: - http_client.with_default_query() - else: - http_client.with_default_data() - - headers = {"X-Forwarded-For": "5.8.8.8"} - http_client.with_headers(headers) - schema = cls._schema - response_decoder = cls.response_decoder() - response_parser = ResponseParser(schema, response_decoder) - return cls(http_client, response_parser) + def inverter_serial_number_getter(cls, response: Dict[str, Any]) -> Optional[str]: + return response["information"][2] @classmethod def build_all_variants(cls, host, port, pwd=""): @@ -76,4 +65,8 @@ def build_all_variants(cls, host, port, pwd=""): cls._build(host, port, pwd, True), cls._build(host, port, pwd, False), ] + for inverter in versions: + inverter.http_client = inverter.http_client.with_headers( + {"X-Forwarded-For": "5.8.8.8"} + ) return versions diff --git a/solax/inverters/x1_hybrid_gen4.py b/solax/inverters/x1_hybrid_gen4.py index 0962586..6e2c852 100644 --- a/solax/inverters/x1_hybrid_gen4.py +++ b/solax/inverters/x1_hybrid_gen4.py @@ -1,8 +1,9 @@ +from typing import Any, Dict, Optional + import voluptuous as vol -from solax import utils -from solax.inverter import Inverter, InverterHttpClient, Method, ResponseParser -from solax.units import Total, Units +from solax.inverter import Inverter +from solax.units import DailyTotal, Total, Units from solax.utils import div10, div100, pack_u16, to_signed @@ -15,28 +16,20 @@ class X1HybridGen4(Inverter): "sn", ): str, vol.Required("ver"): str, - vol.Required("Data"): vol.Schema( + vol.Required("data"): vol.Schema( vol.All( [vol.Coerce(float)], vol.Length(min=200, max=200), ) ), - vol.Required("Information"): vol.Schema(vol.All(vol.Length(min=9, max=10))), + vol.Required("information"): vol.Schema(vol.All(vol.Length(min=9, max=10))), }, extra=vol.REMOVE_EXTRA, ) - @classmethod - def _build(cls, host, port, pwd="", params_in_query=True): - url = utils.to_url(host, port) - http_client = InverterHttpClient(url, Method.POST, pwd).with_default_data() - - response_parser = ResponseParser(cls._schema, cls.response_decoder()) - return cls(http_client, response_parser) - @classmethod def build_all_variants(cls, host, port, pwd=""): - versions = [cls._build(host, port, pwd)] + versions = [cls._build(host, port, pwd, False)] return versions @classmethod @@ -53,7 +46,7 @@ def response_decoder(cls): "PV1 power": (8, Units.W), "PV2 power": (9, Units.W), "On-grid total yield": (pack_u16(11, 12), Total(Units.KWH), div10), - "On-grid daily yield": (13, Units.KWH, div10), + "On-grid daily yield": (13, DailyTotal(Units.KWH), div10), "Battery voltage": (14, Units.V, div100), "Battery current": (15, Units.A, div100), "Battery power": (16, Units.W), @@ -63,3 +56,7 @@ def response_decoder(cls): "Total feed-in energy": (pack_u16(34, 35), Total(Units.KWH), div100), "Total consumption": (pack_u16(36, 37), Total(Units.KWH), div100), } + + @classmethod + def inverter_serial_number_getter(cls, response: Dict[str, Any]) -> Optional[str]: + return response["information"][2] diff --git a/solax/inverters/x1_mini.py b/solax/inverters/x1_mini.py index 7d0cb1a..5968e36 100644 --- a/solax/inverters/x1_mini.py +++ b/solax/inverters/x1_mini.py @@ -1,7 +1,9 @@ +from typing import Any, Dict, Optional + import voluptuous as vol from solax.inverter import Inverter -from solax.units import Total, Units +from solax.units import DailyTotal, Total, Units from solax.utils import startswith @@ -10,15 +12,15 @@ class X1Mini(Inverter): _schema = vol.Schema( { vol.Required("type"): vol.All(str, startswith("X1-")), - vol.Required("SN"): str, + vol.Required("sn"): str, vol.Required("ver"): str, - vol.Required("Data"): vol.Schema( + vol.Required("data"): vol.Schema( vol.All( [vol.Coerce(float)], vol.Length(min=69, max=69), ) ), - vol.Required("Information"): vol.Schema(vol.All(vol.Length(min=9, max=9))), + vol.Required("information"): vol.Schema(vol.All(vol.Length(min=9, max=9))), }, extra=vol.REMOVE_EXTRA, ) @@ -34,7 +36,7 @@ def response_decoder(cls): "Network Voltage": (5, Units.V), "AC Power": (6, Units.W), "Inverter Temperature": (7, Units.C), - "Today's Energy": (8, Units.KWH), + "Today's Energy": (8, DailyTotal(Units.KWH)), "Total Energy": (9, Total(Units.KWH)), "Exported Power": (10, Units.W), "PV1 Power": (11, Units.W), @@ -46,3 +48,7 @@ def response_decoder(cls): } # pylint: enable=duplicate-code + + @classmethod + def inverter_serial_number_getter(cls, response: Dict[str, Any]) -> Optional[str]: + return response["information"][3] diff --git a/solax/inverters/x1_mini_v34.py b/solax/inverters/x1_mini_v34.py index 90906ed..89cb163 100644 --- a/solax/inverters/x1_mini_v34.py +++ b/solax/inverters/x1_mini_v34.py @@ -1,7 +1,9 @@ +from typing import Any, Dict, Optional + import voluptuous as vol from solax.inverter import Inverter -from solax.units import Total, Units +from solax.units import DailyTotal, Total, Units from solax.utils import div10, div100 @@ -22,7 +24,7 @@ class X1MiniV34(Inverter): "sn", ): str, vol.Required("ver"): str, - vol.Required("Data"): vol.Schema( + vol.Required("data"): vol.Schema( vol.All( [vol.Coerce(float)], vol.Any( @@ -32,7 +34,7 @@ class X1MiniV34(Inverter): ), ) ), - vol.Required("Information"): vol.Schema( + vol.Required("information"): vol.Schema( vol.Any(vol.Length(min=9, max=9), vol.Length(min=10, max=10)) ), }, @@ -53,7 +55,7 @@ def response_decoder(cls): "PV2 Power": (8, Units.W), "Grid Frequency": (9, Units.HZ, div100), "Total Energy": (11, Total(Units.KWH), div10), - "Today's Energy": (13, Units.KWH, div10), + "Today's Energy": (13, DailyTotal(Units.KWH), div10), "Total Feed-in Energy": (41, Total(Units.KWH), div10), "Total Consumption": (42, Total(Units.KWH), div10), "Power Now": (43, Units.W, div10), @@ -61,3 +63,7 @@ def response_decoder(cls): } # pylint: enable=duplicate-code + + @classmethod + def inverter_serial_number_getter(cls, response: Dict[str, Any]) -> Optional[str]: + return response["information"][2] diff --git a/solax/inverters/x1_smart.py b/solax/inverters/x1_smart.py index 6da7cc0..2a014eb 100644 --- a/solax/inverters/x1_smart.py +++ b/solax/inverters/x1_smart.py @@ -1,8 +1,9 @@ +from typing import Any, Dict, Optional + import voluptuous as vol -from solax import utils -from solax.inverter import Inverter, InverterHttpClient, Method, ResponseParser -from solax.units import Total, Units +from solax.inverter import Inverter +from solax.units import DailyTotal, Total, Units from solax.utils import div10, div100, to_signed @@ -20,13 +21,13 @@ class X1Smart(Inverter): "sn", ): str, vol.Required("ver"): str, - vol.Required("Data"): vol.Schema( + vol.Required("data"): vol.Schema( vol.All( [vol.Coerce(float)], vol.Length(min=200, max=200), ) ), - vol.Required("Information"): vol.Schema(vol.All(vol.Length(min=8, max=8))), + vol.Required("information"): vol.Schema(vol.All(vol.Length(min=8, max=8))), }, extra=vol.REMOVE_EXTRA, ) @@ -45,7 +46,7 @@ def response_decoder(cls): "PV2 Power": (8, Units.W), "Grid Frequency": (9, Units.HZ, div100), "Total Energy": (11, Total(Units.KWH), div10), - "Today's Energy": (13, Units.KWH, div10), + "Today's Energy": (13, DailyTotal(Units.KWH), div10), "Inverter Temperature": (39, Units.C), "Exported Power": (48, Units.W, to_signed), "Total Feed-in Energy": (50, Total(Units.KWH), div100), @@ -53,20 +54,8 @@ def response_decoder(cls): } @classmethod - def _build(cls, host, port, pwd="", params_in_query=True): - url = utils.to_url(host, port) - http_client = InverterHttpClient(url, Method.POST, pwd) - if params_in_query: - http_client.with_default_query() - else: - http_client.with_default_data() - - headers = {"X-Forwarded-For": "5.8.8.8"} - http_client.with_headers(headers) - schema = cls._schema - response_decoder = cls.response_decoder() - response_parser = ResponseParser(schema, response_decoder) - return cls(http_client, response_parser) + def inverter_serial_number_getter(cls, response: Dict[str, Any]) -> Optional[str]: + return response["information"][2] @classmethod def build_all_variants(cls, host, port, pwd=""): @@ -74,4 +63,8 @@ def build_all_variants(cls, host, port, pwd=""): cls._build(host, port, pwd, True), cls._build(host, port, pwd, False), ] + for inverter in versions: + inverter.http_client = inverter.http_client.with_headers( + {"X-Forwarded-For": "5.8.8.8"} + ) return versions diff --git a/solax/inverters/x3.py b/solax/inverters/x3.py index f69c7ad..f562b0f 100644 --- a/solax/inverters/x3.py +++ b/solax/inverters/x3.py @@ -1,28 +1,30 @@ +from typing import Any, Dict, Optional + import voluptuous as vol from solax.inverter import Inverter -from solax.units import Total, Units +from solax.units import DailyTotal, Total, Units from solax.utils import startswith class X3(Inverter): + # pylint: disable=duplicate-code _schema = vol.Schema( { vol.Required("type"): vol.All(str, startswith("X3-")), - vol.Required("SN"): str, + vol.Required("sn"): str, vol.Required("ver"): str, - vol.Required("Data"): vol.Schema( + vol.Required("data"): vol.Schema( vol.All( [vol.Coerce(float)], vol.Any(vol.Length(min=102, max=103), vol.Length(min=107, max=107)), ) ), - vol.Required("Information"): vol.Schema(vol.All(vol.Length(min=9, max=9))), + vol.Required("information"): vol.Schema(vol.All(vol.Length(min=9, max=9))), }, extra=vol.REMOVE_EXTRA, ) - # pylint: disable=duplicate-code @classmethod def response_decoder(cls): return { @@ -34,7 +36,7 @@ def response_decoder(cls): "Network Voltage Phase 1": (5, Units.V), "AC Power": (6, Units.W), "Inverter Temperature": (7, Units.C), - "Today's Energy": (8, Units.KWH), + "Today's Energy": (8, DailyTotal(Units.KWH)), "Total Energy": (9, Total(Units.KWH)), "Exported Power": (10, Units.W), "PV1 Power": (11, Units.W), @@ -61,3 +63,7 @@ def response_decoder(cls): "EPS Power": (55, Units.W), "EPS Frequency": (56, Units.HZ), } + + @classmethod + def inverter_serial_number_getter(cls, response: Dict[str, Any]) -> Optional[str]: + return response["information"][3] diff --git a/solax/inverters/x3_hybrid_g4.py b/solax/inverters/x3_hybrid_g4.py index 34048da..2665060 100644 --- a/solax/inverters/x3_hybrid_g4.py +++ b/solax/inverters/x3_hybrid_g4.py @@ -1,7 +1,9 @@ +from typing import Any, Dict, Optional + import voluptuous as vol from solax.inverter import Inverter -from solax.units import Total, Units +from solax.units import DailyTotal, Measurement, Total, Units from solax.utils import ( div10, div100, @@ -22,13 +24,13 @@ class X3HybridG4(Inverter): vol.Required("type"): vol.All(int, 14), vol.Required("sn"): str, vol.Required("ver"): str, - vol.Required("Data"): vol.Schema( + vol.Required("data"): vol.Schema( vol.All( [vol.Coerce(float)], vol.Length(min=300, max=300), ) ), - vol.Required("Information"): vol.Schema( + vol.Required("information"): vol.Schema( vol.All(vol.Length(min=10, max=10)) ), }, @@ -104,28 +106,36 @@ def response_decoder(cls): "Load/Generator Power": (47, Units.W, to_signed), "Radiator Temperature": (54, Units.C, to_signed), "Yield total": (pack_u16(68, 69), Total(Units.KWH), div10), - "Yield today": (70, Units.KWH, div10), + "Yield today": (70, DailyTotal(Units.KWH), div10), "Battery Discharge Energy total": ( pack_u16(74, 75), Total(Units.KWH), div10, ), "Battery Charge Energy total": (pack_u16(76, 77), Total(Units.KWH), div10), - "Battery Discharge Energy today": (78, Units.KWH, div10), - "Battery Charge Energy today": (79, Units.KWH, div10), + "Battery Discharge Energy today": (78, DailyTotal(Units.KWH), div10), + "Battery Charge Energy today": (79, DailyTotal(Units.KWH), div10), "PV Energy total": (pack_u16(80, 81), Total(Units.KWH), div10), "EPS Energy total": (pack_u16(83, 84), Total(Units.KWH), div10), - "EPS Energy today": (85, Units.KWH, div10), + "EPS Energy today": (85, DailyTotal(Units.KWH), div10), "Feed-in Energy": (pack_u16(86, 87), Total(Units.KWH), div100), "Consumed Energy": (pack_u16(88, 89), Total(Units.KWH), div100), "Feed-in Energy total": (pack_u16(90, 91), Total(Units.KWH), div100), "Consumed Energy total": (pack_u16(92, 93), Total(Units.KWH), div100), "Battery Remaining Capacity": (103, Units.PERCENT), "Battery Temperature": (105, Units.C, to_signed), - "Battery Remaining Energy": (106, Units.KWH, div10), + "Battery Remaining Energy": ( + 106, + Measurement(Units.KWH, storage=True), + div10, + ), "Battery mode": (168, Units.NONE), "Battery mode text": (168, Units.NONE, X3HybridG4._decode_battery_mode), "Battery Voltage": (pack_u16(169, 170), Units.V, div100), } # pylint: enable=duplicate-code + + @classmethod + def inverter_serial_number_getter(cls, response: Dict[str, Any]) -> Optional[str]: + return response["information"][2] diff --git a/solax/inverters/x3_mic_pro_g2.py b/solax/inverters/x3_mic_pro_g2.py index 76df85f..93c7a63 100644 --- a/solax/inverters/x3_mic_pro_g2.py +++ b/solax/inverters/x3_mic_pro_g2.py @@ -1,7 +1,9 @@ +from typing import Any, Dict, Optional + import voluptuous as vol from solax.inverter import Inverter -from solax.units import Total, Units +from solax.units import DailyTotal, Total, Units from solax.utils import div10, div100, pack_u16, to_signed, to_signed32, twoway_div10 @@ -14,13 +16,13 @@ class X3MicProG2(Inverter): vol.Required("type"): vol.All(int, 16), vol.Required("sn"): str, vol.Required("ver"): str, - vol.Required("Data"): vol.Schema( + vol.Required("data"): vol.Schema( vol.All( [vol.Coerce(float)], vol.Length(min=100, max=100), ) ), - vol.Required("Information"): vol.Schema( + vol.Required("information"): vol.Schema( vol.All(vol.Length(min=10, max=10)) ), }, @@ -69,10 +71,14 @@ def response_decoder(cls): # "Run Mode": (21, Units.NONE), "Run Mode": (21, Units.NONE, X3MicProG2._decode_run_mode), "Total Yield": (pack_u16(22, 23), Total(Units.KWH), div10), - "Daily Yield": (24, Units.KWH, div10), + "Daily Yield": (24, DailyTotal(Units.KWH), div10), "Feed-in Power ": (pack_u16(72, 73), Units.W, to_signed32), "Total Feed-in Energy": (pack_u16(74, 75), Total(Units.KWH), div100), "Total Consumption": (pack_u16(76, 77), Total(Units.KWH), div100), } # pylint: enable=duplicate-code + + @classmethod + def inverter_serial_number_getter(cls, response: Dict[str, Any]) -> Optional[str]: + return response["information"][2] diff --git a/solax/inverters/x3_v34.py b/solax/inverters/x3_v34.py index 029f912..af2eb42 100644 --- a/solax/inverters/x3_v34.py +++ b/solax/inverters/x3_v34.py @@ -1,7 +1,9 @@ +from typing import Any, Dict, Optional + import voluptuous as vol from solax.inverter import Inverter -from solax.units import Total, Units +from solax.units import DailyTotal, Measurement, Total, Units from solax.utils import div10, div100, pack_u16, to_signed, twoway_div10, twoway_div100 @@ -14,13 +16,13 @@ class X3V34(Inverter): vol.Required("type"): vol.All(int, 5), vol.Required("sn"): str, vol.Required("ver"): str, - vol.Required("Data"): vol.Schema( + vol.Required("data"): vol.Schema( vol.All( [vol.Coerce(float)], vol.Length(min=200, max=200), ) ), - vol.Required("Information"): vol.Schema( + vol.Required("information"): vol.Schema( vol.All(vol.Length(min=10, max=10)) ), }, @@ -46,12 +48,12 @@ def response_decoder(cls): "PV1 Power": (13, Units.W), "PV2 Power": (14, Units.W), "Total PV Energy": (pack_u16(89, 90), Total(Units.KWH), div10), - "Today's PV Energy": (112, Units.KWH, div10), + "Today's PV Energy": (112, DailyTotal(Units.KWH), div10), "Grid Frequency Phase 1": (15, Units.HZ, div100), "Grid Frequency Phase 2": (16, Units.HZ, div100), "Grid Frequency Phase 3": (17, Units.HZ, div100), "Total Energy": (pack_u16(19, 20), Total(Units.KWH), div10), - "Today's Energy": (21, Units.KWH, div10), + "Today's Energy": (21, DailyTotal(Units.KWH), div10), "Battery Voltage": (24, Units.V, div100), "Battery Current": (25, Units.A, twoway_div100), "Battery Power": (26, Units.W, to_signed), @@ -62,20 +64,32 @@ def response_decoder(cls): Total(Units.KWH), div10, ), - "Today's Battery Discharge Energy": (113, Units.KWH, div10), - "Battery Remaining Energy": (32, Units.KWH, div10), + "Today's Battery Discharge Energy": (113, DailyTotal(Units.KWH), div10), + "Battery Remaining Energy": ( + 32, + Measurement(Units.KWH, storage=True), + div10, + ), "Total Battery Charge Energy": ( pack_u16(87, 88), Total(Units.KWH), div10, ), - "Today's Battery Charge Energy": (114, Units.KWH, div10), + "Today's Battery Charge Energy": (114, DailyTotal(Units.KWH), div10), "Exported Power": (65, Units.W, to_signed), "Total Feed-in Energy": (pack_u16(67, 68), Total(Units.KWH), div100), "Total Consumption": (pack_u16(69, 70), Total(Units.KWH), div100), "AC Power": (181, Units.W, to_signed), "EPS Frequency": (63, Units.HZ, div100), - "EPS Total Energy": (pack_u16(110, 111), Units.KWH, div10), + "EPS Total Energy": ( + pack_u16(110, 111), + Measurement(Units.KWH, storage=False), + div10, + ), } # pylint: enable=duplicate-code + + @classmethod + def inverter_serial_number_getter(cls, response: Dict[str, Any]) -> Optional[str]: + return response["information"][2] diff --git a/solax/inverters/x_hybrid.py b/solax/inverters/x_hybrid.py index f620c99..f2b3b55 100644 --- a/solax/inverters/x_hybrid.py +++ b/solax/inverters/x_hybrid.py @@ -1,7 +1,9 @@ +from typing import Any, Dict, Optional + import voluptuous as vol -from solax.inverter import Inverter, InverterHttpClient, Method, ResponseParser -from solax.units import Total, Units +from solax.inverter import Inverter, InverterHttpClient, Method +from solax.units import DailyTotal, Total, Units class XHybrid(Inverter): @@ -15,14 +17,14 @@ class XHybrid(Inverter): vol.Required("method"): str, vol.Required("version"): str, vol.Required("type"): str, - vol.Required("SN"): str, - vol.Required("Data"): vol.Schema( + vol.Required("sn"): str, + vol.Required("data"): vol.Schema( vol.All( [vol.Coerce(float)], vol.Any(vol.Length(min=58, max=58), vol.Length(min=68, max=68)), ) ), - vol.Required("Status"): vol.All(vol.Coerce(int), vol.Range(min=0)), + vol.Required("status"): vol.All(vol.Coerce(int), vol.Range(min=0)), }, extra=vol.REMOVE_EXTRA, ) @@ -31,9 +33,9 @@ class XHybrid(Inverter): def _build(cls, host, port, pwd="", params_in_query=True): base = "http://{}:{}/api/realTimeData.htm" url = base.format(host, port) - http_client = InverterHttpClient.build_w_url(url, Method.GET) - response_parser = ResponseParser(cls._schema, cls.response_decoder()) - return cls(http_client, response_parser) + http_client = InverterHttpClient(url=url, method=Method.GET, pwd="") + + return cls(http_client) @classmethod def build_all_variants(cls, host, port, pwd=""): @@ -55,7 +57,7 @@ def response_decoder(cls): "Network Voltage": (5, Units.V), "Power Now": (6, Units.W), "Inverter Temperature": (7, Units.C), - "Today's Energy": (8, Units.KWH), + "Today's Energy": (8, DailyTotal(Units.KWH)), "Total Energy": (9, Total(Units.KWH)), "Exported Power": (10, Units.W), "PV1 Power": (11, Units.W), @@ -74,3 +76,7 @@ def response_decoder(cls): "EPS Power": (55, Units.W), "EPS Frequency": (56, Units.HZ), } + + @classmethod + def inverter_serial_number_getter(cls, response: Dict[str, Any]) -> Optional[str]: + return None diff --git a/solax/response_parser.py b/solax/response_parser.py index 5d6cd85..027e236 100644 --- a/solax/response_parser.py +++ b/solax/response_parser.py @@ -1,7 +1,8 @@ import json import logging +import sys from collections import namedtuple -from typing import Any, Callable, Dict, Tuple, Union +from typing import Any, Callable, Dict, Generator, Optional, Tuple, Union import voluptuous as vol from voluptuous import Invalid, MultipleInvalid @@ -10,25 +11,54 @@ from solax.units import SensorUnit from solax.utils import PackerBuilderResult +__all__ = ("ResponseParser", "InverterResponse", "ResponseDecoder") + +if sys.version_info >= (3, 11): + from typing import Unpack +else: + from typing_extensions import Unpack + _LOGGER = logging.getLogger(__name__) _LOGGER.setLevel(logging.INFO) -InverterResponse = namedtuple("InverterResponse", "data, serial_number, version, type") +class InverterResponse( + namedtuple( + "InverterResponse", + [ + "data", + "dongle_serial_number", + "version", + "type", + "inverter_serial_number", + ], + ) +): + @property + def serial_number(self): + return self.dongle_serial_number + + +ProcessorTuple = Tuple[Callable[[Any], Any], ...] SensorIndexSpec = Union[int, PackerBuilderResult] ResponseDecoder = Dict[ str, - Union[ - Tuple[SensorIndexSpec, SensorUnit], - Tuple[SensorIndexSpec, SensorUnit, Callable[[Any], Any]], - ], + Tuple[SensorIndexSpec, SensorUnit, Unpack[ProcessorTuple]], ] class ResponseParser: - def __init__(self, schema: vol.Schema, decoder: ResponseDecoder): + def __init__( + self, + schema: vol.Schema, + decoder: ResponseDecoder, + dongle_serial_number_getter: Callable[[Dict[str, Any]], Optional[str]], + inverter_serial_number_getter: Callable[[Dict[str, Any]], Optional[str]], + ) -> None: self.schema = schema self.response_decoder = decoder + self.dongle_serial_number_getter = dongle_serial_number_getter + self.inverter_serial_number_getter = inverter_serial_number_getter def _decode_map(self) -> Dict[str, SensorIndexSpec]: sensors: Dict[str, SensorIndexSpec] = {} @@ -36,17 +66,16 @@ def _decode_map(self) -> Dict[str, SensorIndexSpec]: sensors[name] = mapping[0] return sensors - def _postprocess_map(self) -> Dict[str, Callable[[Any], Any]]: + def _postprocess_gen( + self, + ) -> Generator[Tuple[str, Callable[[Any], Any]], None, None]: """ Return map of functions to be applied to each sensor value """ - sensors: Dict[str, Callable[[Any], Any]] = {} for name, mapping in self.response_decoder.items(): - processor = None - (_, _, *processor) = mapping - if processor: - sensors[name] = processor[0] - return sensors + (_, _, *processors) = mapping + for processor in processors: + yield name, processor def map_response(self, resp_data) -> Dict[str, Any]: result = {} @@ -59,11 +88,11 @@ def map_response(self, resp_data) -> Dict[str, Any]: else: val = resp_data[decode_info] result[sensor_name] = val - for sensor_name, processor in self._postprocess_map().items(): + for sensor_name, processor in self._postprocess_gen(): result[sensor_name] = processor(result[sensor_name]) return result - def handle_response(self, resp: bytearray): + def handle_response(self, resp: bytearray) -> InverterResponse: """ Decode response and map array result using mapping definition. @@ -75,15 +104,20 @@ def handle_response(self, resp: bytearray): """ raw_json = resp.decode("utf-8").replace(",,", ",0.0,").replace(",,", ",0.0,") - json_response = json.loads(raw_json) + json_response = {} + for key, value in json.loads(raw_json).items(): + json_response[key.lower()] = value + try: response = self.schema(json_response) except (Invalid, MultipleInvalid) as ex: _ = humanize_error(json_response, ex) raise + return InverterResponse( - data=self.map_response(response["Data"]), - serial_number=response.get("SN", response.get("sn")), + data=self.map_response(response["data"]), + dongle_serial_number=self.dongle_serial_number_getter(response), version=response.get("ver", response.get("version")), type=response["type"], + inverter_serial_number=self.inverter_serial_number_getter(response), ) diff --git a/solax/units.py b/solax/units.py index f8ca1c3..eab9cbf 100644 --- a/solax/units.py +++ b/solax/units.py @@ -22,6 +22,8 @@ class Measurement(NamedTuple): unit: Units is_monotonic: bool = False + resets_daily: bool = False + storage: bool = False class Total(Measurement): @@ -30,4 +32,10 @@ class Total(Measurement): is_monotonic: bool = True +class DailyTotal(Measurement): + """A Measurement where the values are reset daily.""" + + resets_daily: bool = True + + SensorUnit = Union[Measurement, Total] diff --git a/tests/samples/responses.py b/tests/samples/responses.py index 64304a9..0e4078f 100644 --- a/tests/samples/responses.py +++ b/tests/samples/responses.py @@ -1517,7 +1517,7 @@ 3.000, 3, "X1-Hybiyd-G3", - "YYYYYYYYYYYYYY", + "XXXXXXXXXXXXXX", 1, 3.11, 0.00, @@ -1535,7 +1535,7 @@ X1_HYBRID_G4_RESPONSE = { "type": 15, - "sn": "SXxxxxxxxx", + "sn": "SXXXXXXXXX", "ver": "3.003.02", "Data": [ 2470, @@ -1742,7 +1742,7 @@ "Information": [ 5.000, 15, - "H450xxxxxxxxxx", + "H450XXXXXXXXXX", 8, 1.24, 0.00, @@ -2614,7 +2614,7 @@ } X3_HYBRID_G4_RESPONSE = { - "sn": "SR3xxxxxxx", + "sn": "SR3XXXXXXX", "ver": "3.006.04", "type": 14, "Data": [ @@ -2919,11 +2919,11 @@ 0, 0, ], - "Information": [10.000, 14, "H34A**********", 8, 1.23, 0.00, 1.24, 1.09, 0.00, 1], + "Information": [10.000, 14, "H34AXXXXXXXXXX", 8, 1.23, 0.00, 1.24, 1.09, 0.00, 1], } X3_MICPRO_G2_RESPONSE = { - "sn": "SRE*******", + "sn": "SREXXXXXXX", "ver": "3.008.10", "type": 16, "Data": [ @@ -3028,11 +3028,11 @@ 0, 0, ], - "Information": [4.000, 16, "MC20**********", 8, 1.20, 0.00, 1.18, 1.00, 0.00, 1], + "Information": [4.000, 16, "MC20XXXXXXXXXX", 8, 1.20, 0.00, 1.18, 1.00, 0.00, 1], } QVOLTHYBG33P_RESPONSE_V34 = { - "sn": "SWX***", + "sn": "SWXXXX", "ver": "2.034.06", "type": 14, "Data": [ @@ -3237,5 +3237,5 @@ 0, 0, ], - "Information": [12.0, 14, "H34***", 1, 1.15, 0.0, 1.14, 1.07, 0.0, 1], + "Information": [12.0, 14, "H34XXXXXXXX", 1, 1.15, 0.0, 1.14, 1.07, 0.0, 1], } diff --git a/tests/test_base_inverter.py b/tests/test_base_inverter.py index 3cab1a1..fa69619 100644 --- a/tests/test_base_inverter.py +++ b/tests/test_base_inverter.py @@ -5,6 +5,7 @@ def test_all_registered_inverters_inherit_from_base(): + assert REGISTRY for i in REGISTRY: assert issubclass(i, Inverter) @@ -12,4 +13,4 @@ def test_all_registered_inverters_inherit_from_base(): def test_unimplemented_response_decoder(): with pytest.raises(NotImplementedError): versions = Inverter.build_all_variants("localhost", 80) - versions[0].response_decoder() + next(iter(versions)).response_decoder() diff --git a/tests/test_discovery.py b/tests/test_discovery.py index 2f57b50..3a2f956 100644 --- a/tests/test_discovery.py +++ b/tests/test_discovery.py @@ -1,14 +1,119 @@ +import asyncio + import pytest import solax -from solax.discovery import DiscoveryError +from solax import InverterResponse +from solax.discovery import REGISTRY, DiscoveryError +from solax.inverter import InverterError +from solax.inverters import X1Boost + + +class DelayedX1Boost(X1Boost): + async def get_data(self) -> InverterResponse: + await asyncio.sleep(10) + return await super().get_data() + + +class DelayedFailedX1Boost(X1Boost): + async def make_request(self) -> InverterResponse: + await asyncio.sleep(5) + raise InverterError @pytest.mark.asyncio async def test_discovery(inverters_fixture): conn, inverter_class, _ = inverters_fixture + inverters = await solax.discover(*conn, return_when=asyncio.ALL_COMPLETED) + assert inverter_class in {type(inverter) for inverter in inverters} + + for inverter in inverters: + if isinstance(inverter, inverter_class): + data = await inverter.get_data() + assert "X" * 7 in (data.inverter_serial_number or "X" * 7) + assert data.serial_number == data.dongle_serial_number + + +@pytest.mark.asyncio +async def test_real_time_api(inverters_fixture): + conn, inverter_class, _ = inverters_fixture + + if inverter_class is not X1Boost: + pytest.skip() + rt_api = await solax.real_time_api(*conn) - assert rt_api.inverter.__class__ == inverter_class + assert rt_api.inverter.__class__ is inverter_class + + +@pytest.mark.asyncio +async def test_discovery_cancelled_error_while_staggering( + inverters_fixture, +): + conn, inverter_class, _ = inverters_fixture + + if inverter_class is not X1Boost: + pytest.skip() + + task = asyncio.create_task( + solax.discover(*conn, return_when=asyncio.FIRST_EXCEPTION) + ) + await asyncio.sleep(1) + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + + +@pytest.mark.asyncio +async def test_discovery_cancelled_error_after_staggering( + inverters_fixture, +): + conn, inverter_class, _ = inverters_fixture + + if inverter_class is not X1Boost: + pytest.skip() + + inverters = set(REGISTRY) + inverters.add(DelayedX1Boost) + + task = asyncio.create_task( + solax.discover(*conn, inverters=inverters, return_when=asyncio.FIRST_EXCEPTION) + ) + await asyncio.sleep(7) + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + + +@pytest.mark.asyncio +async def test_discovery_first_completed_after_staggering( + inverters_fixture, +): + conn, inverter_class, _ = inverters_fixture + + if inverter_class is not X1Boost: + pytest.skip() + + inverter = await solax.discover( + *conn, inverters=[DelayedX1Boost], return_when=asyncio.FIRST_COMPLETED + ) + assert inverter.__class__ is DelayedX1Boost + + +@pytest.mark.asyncio +async def test_discovery_not_first_completed_after_staggering( + inverters_fixture, +): + conn, inverter_class, _ = inverters_fixture + + if inverter_class is not X1Boost: + pytest.skip() + + inverters = await solax.discover( + *conn, + inverters=[DelayedX1Boost, DelayedFailedX1Boost], + return_when=asyncio.FIRST_EXCEPTION + ) + assert DelayedX1Boost in {type(inverter) for inverter in inverters} @pytest.mark.asyncio @@ -27,3 +132,9 @@ async def test_discovery_no_host_with_pwd(): async def test_discovery_unknown_webserver(simple_http_fixture): with pytest.raises(DiscoveryError): await solax.real_time_api(*simple_http_fixture) + + +@pytest.mark.asyncio +async def test_discovery_empty_inverter_class_iterable(): + with pytest.raises(DiscoveryError): + await solax.discover("localhost", 2, inverters=[])