diff --git a/solax/inverter.py b/solax/inverter.py index 1290332..812362b 100644 --- a/solax/inverter.py +++ b/solax/inverter.py @@ -5,6 +5,7 @@ import voluptuous as vol from voluptuous import Invalid, MultipleInvalid from voluptuous.humanize import humanize_error +from solax.utils import contains_none_zero_value from solax.units import Measurement, SensorUnit, Units @@ -15,6 +16,23 @@ class InverterError(Exception): InverterResponse = namedtuple("InverterResponse", "data, serial_number, version, type") +_KEY_DATA = "Data" +_KEY_SERIAL = "SN" +_KEY_VERSION = "version" +_KEY_VER = "ver" +_KEY_TYPE = "type" + + +DataSchema = 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, +) + class Inverter: """Base wrapper around Inverter HTTP API""" @@ -116,25 +134,6 @@ def map_response(cls, resp_data) -> Dict[str, Any]: result[sensor_name] = processor(result[sensor_name], result) return result - -class InverterPost(Inverter): - # This is an intermediate abstract class, - # so we can disable the pylint warning - # pylint: disable=W0223,R0914 - @classmethod - async def make_request(cls, host, port=80, pwd="", headers=None): - if not pwd: - base = "http://{}:{}/?optType=ReadRealTimeData" - url = base.format(host, port) - else: - base = "http://{}:{}/?optType=ReadRealTimeData&pwd={}&" - url = base.format(host, port, pwd) - async with aiohttp.ClientSession() as session: - async with session.post(url, headers=headers) as req: - resp = await req.read() - - return cls.handle_response(resp) - @classmethod def handle_response(cls, resp: bytearray): """ @@ -149,15 +148,36 @@ def handle_response(cls, resp: bytearray): raw_json = resp.decode("utf-8") json_response = json.loads(raw_json) - response = {} + response = {} # type: dict try: response = cls.schema()(json_response) + DataSchema(json_response) except (Invalid, MultipleInvalid) as ex: _ = humanize_error(json_response, ex) raise + return InverterResponse( - data=cls.map_response(response["Data"]), - serial_number=response.get("SN", response.get("sn")), - version=response["ver"], - type=response["type"], + data=cls.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], ) + + +class InverterPost(Inverter): + # This is an intermediate abstract class, + # so we can disable the pylint warning + # pylint: disable=W0223,R0914 + @classmethod + async def make_request(cls, host, port=80, pwd="", headers=None): + if not pwd: + base = "http://{}:{}/?optType=ReadRealTimeData" + url = base.format(host, port) + else: + base = "http://{}:{}/?optType=ReadRealTimeData&pwd={}&" + url = base.format(host, port, pwd) + async with aiohttp.ClientSession() as session: + async with session.post(url, headers=headers) as req: + resp = await req.read() + + return cls.handle_response(resp) diff --git a/solax/inverters/x_hybrid.py b/solax/inverters/x_hybrid.py index 0ab65f6..d7032be 100644 --- a/solax/inverters/x_hybrid.py +++ b/solax/inverters/x_hybrid.py @@ -1,7 +1,6 @@ -import json import aiohttp import voluptuous as vol -from solax.inverter import Inverter, InverterResponse +from solax.inverter import Inverter from solax.units import Total, Units @@ -70,11 +69,5 @@ async def make_request(cls, host, port=80, pwd="", headers=None): garbage = await req.read() formatted = garbage.decode("utf-8") formatted = formatted.replace(",,", ",0.0,").replace(",,", ",0.0,") - json_response = json.loads(formatted) - response = cls.schema()(json_response) - return InverterResponse( - data=cls.map_response(response["Data"]), - serial_number=response["SN"], - version=response["version"], - type=response["type"], - ) + + return cls.handle_response(formatted.encode("utf-8")) diff --git a/solax/utils.py b/solax/utils.py index c38b934..0b8c5be 100644 --- a/solax/utils.py +++ b/solax/utils.py @@ -1,3 +1,5 @@ +from numbers import Number +from typing import List from voluptuous import Invalid @@ -85,3 +87,19 @@ def twoway_div10(val, *_args, **_kwargs): def twoway_div100(val, *_args, **_kwargs): return to_signed(val, None) / 100 + + +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 2ce5f67..cdc5917 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -3,3 +3,4 @@ from tests.fixtures import inverters_garbage_fixture # noqa: F401 from tests.fixtures import inverters_fixture # noqa: F401 from tests.fixtures import inverters_under_test # noqa: F401 +from tests.fixtures import inverters_fixture_all_zero # noqa: F401 diff --git a/tests/fixtures.py b/tests/fixtures.py index c62b1a0..e002300 100644 --- a/tests/fixtures.py +++ b/tests/fixtures.py @@ -1,4 +1,5 @@ from collections import namedtuple +from copy import copy import pytest import solax.inverters as inverter from tests.samples.expected_values import ( @@ -193,6 +194,29 @@ def inverters_under_test(request): yield 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, + ).respond_with_json(response) + yield ( + (httpserver.host, httpserver.port), + request.param.inverter, + request.param.values, + ) + + @pytest.fixture(params=INVERTERS_UNDER_TEST) def inverters_fixture(httpserver, request): httpserver.expect_request( diff --git a/tests/test_smoke.py b/tests/test_smoke.py index 1012ebf..419da51 100644 --- a/tests/test_smoke.py +++ b/tests/test_smoke.py @@ -1,11 +1,26 @@ import pytest import solax -from solax.inverter import InverterError +from solax.inverter import Inverter, InverterError from solax.discovery import REGISTRY from solax.units import Measurement from tests import fixtures +@pytest.mark.asyncio +async def test_smoke_zero(inverters_fixture_all_zero): + """Respones with all zero values should be treated as an error. + + Args: + inverters_fixture_all_zero (_type_): all reponses with zero value data + """ + conn, inverter_class, _ = inverters_fixture_all_zero + inverter: Inverter = inverter_class(*conn) + + # msg = 'all zero values should be discarded' + with pytest.raises(InverterError): + await inverter.get_data() + + @pytest.mark.asyncio async def test_smoke(inverters_fixture): conn, inverter_class, values = inverters_fixture diff --git a/tests/test_vol.py b/tests/test_vol.py index 941052d..0a11709 100644 --- a/tests/test_vol.py +++ b/tests/test_vol.py @@ -1,6 +1,6 @@ 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(): @@ -22,3 +22,24 @@ 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