diff --git a/setup.py b/setup.py index 8bff1e6..759b61e 100644 --- a/setup.py +++ b/setup.py @@ -14,7 +14,11 @@ license="MIT", url="https://github.com/squishykid/solax", packages=setuptools.find_packages(exclude=["tests", "tests.*"]), - install_requires=["aiohttp>=3.5.4, <4", "voluptuous>=0.11.5"], + install_requires=[ + "aiohttp>=3.5.4, <4", + "async_timeout>=4.0.2", + "voluptuous>=0.11.5", + ], setup_requires=[ "setuptools_scm", ], diff --git a/solax/discovery.py b/solax/discovery.py index bcdb2b4..abe267d 100644 --- a/solax/discovery.py +++ b/solax/discovery.py @@ -9,10 +9,22 @@ X1Smart, QVOLTHYBG33P, X1Boost, + X1HybridGen4, ) # registry of inverters -REGISTRY = [XHybrid, X3, X3V34, X1, X1Mini, X1MiniV34, X1Smart, QVOLTHYBG33P, X1Boost] +REGISTRY = [ + XHybrid, + X3, + X3V34, + X1, + X1Mini, + X1MiniV34, + X1Smart, + QVOLTHYBG33P, + X1Boost, + X1HybridGen4, +] class DiscoveryError(Exception): diff --git a/solax/inverter.py b/solax/inverter.py index 1290332..bda086d 100644 --- a/solax/inverter.py +++ b/solax/inverter.py @@ -7,6 +7,7 @@ from voluptuous.humanize import humanize_error from solax.units import Measurement, SensorUnit, Units +from solax.utils import PackerBuilderResult class InverterError(Exception): @@ -15,18 +16,21 @@ class InverterError(Exception): InverterResponse = namedtuple("InverterResponse", "data, serial_number, version, type") +SensorIndexSpec = Union[int, PackerBuilderResult] +ResponseDecoder = Dict[ + str, + Union[ + Tuple[SensorIndexSpec, SensorUnit], + Tuple[SensorIndexSpec, SensorUnit, Callable[[Any], Any]], + ], +] + class Inverter: """Base wrapper around Inverter HTTP API""" - ResponseDecoderType = Union[ - Dict[str, int], - Dict[str, Tuple[int, SensorUnit]], - Dict[str, Tuple[int, SensorUnit, Callable[[Any, Any], Any]]], - ] - @classmethod - def response_decoder(cls) -> ResponseDecoderType: + def response_decoder(cls) -> ResponseDecoder: """ Inverter implementations should override this to return a decoding map @@ -68,35 +72,43 @@ async def make_request(cls, host, port, pwd="", headers=None) -> InverterRespons def sensor_map(cls) -> Dict[str, Tuple[int, Measurement]]: """ Return sensor map + Warning, HA depends on this """ - sensors = {} + sensors: Dict[str, Tuple[int, Measurement]] = {} for name, mapping in cls.response_decoder().items(): unit = Measurement(Units.NONE) - if isinstance(mapping, tuple): - (idx, unit_or_measurement, *_) = mapping - else: - idx = mapping + (idx, unit_or_measurement, *_) = mapping if isinstance(unit_or_measurement, Units): unit = Measurement(unit_or_measurement) else: unit = unit_or_measurement + if isinstance(idx, tuple): + sensor_indexes = idx[0] + first_sensor_index = sensor_indexes[0] + idx = first_sensor_index sensors[name] = (idx, unit) return sensors @classmethod - def postprocess_map(cls) -> Dict[str, Callable[[Any, Any], Any]]: + def _decode_map(cls) -> Dict[str, SensorIndexSpec]: + sensors: Dict[str, SensorIndexSpec] = {} + for name, mapping in cls.response_decoder().items(): + sensors[name] = mapping[0] + return sensors + + @classmethod + def _postprocess_map(cls) -> Dict[str, Callable[[Any], Any]]: """ Return map of functions to be applied to each sensor value """ - sensors = {} + sensors: Dict[str, Callable[[Any], Any]] = {} for name, mapping in cls.response_decoder().items(): - if isinstance(mapping, tuple): - processor = None - (_, _, *processor) = mapping - if processor: - sensors[name] = processor[0] + processor = None + (_, _, *processor) = mapping + if processor: + sensors[name] = processor[0] return sensors @classmethod @@ -109,11 +121,17 @@ def schema(cls) -> vol.Schema: @classmethod def map_response(cls, resp_data) -> Dict[str, Any]: result = {} - for sensor_name, (idx, _) in cls.sensor_map().items(): - val = resp_data[idx] + for sensor_name, decode_info in cls._decode_map().items(): + if isinstance(decode_info, (tuple, list)): + indexes = decode_info[0] + packer = decode_info[1] + values = tuple(resp_data[i] for i in indexes) + val = packer(*values) + else: + val = resp_data[decode_info] result[sensor_name] = val - for sensor_name, processor in cls.postprocess_map().items(): - result[sensor_name] = processor(result[sensor_name], result) + for sensor_name, processor in cls._postprocess_map().items(): + result[sensor_name] = processor(result[sensor_name]) return result @@ -131,6 +149,7 @@ async def make_request(cls, host, port=80, pwd="", headers=None): url = base.format(host, port, pwd) async with aiohttp.ClientSession() as session: async with session.post(url, headers=headers) as req: + req.raise_for_status() resp = await req.read() return cls.handle_response(resp) @@ -161,3 +180,24 @@ def handle_response(cls, resp: bytearray): version=response["ver"], type=response["type"], ) + + +class InverterPostData(InverterPost): + # 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): + base = "http://{}:{}/" + url = base.format(host, port) + data = "optType=ReadRealTimeData" + if pwd: + data = data + "&pwd=" + pwd + async with aiohttp.ClientSession() as session: + async with session.post( + url, headers=headers, data=data.encode("utf-8") + ) as req: + req.raise_for_status() + resp = await req.read() + + return cls.handle_response(resp) diff --git a/solax/inverters/__init__.py b/solax/inverters/__init__.py index 917e8d6..4d45504 100644 --- a/solax/inverters/__init__.py +++ b/solax/inverters/__init__.py @@ -1,6 +1,7 @@ from .qvolt_hyb_g3_3p import QVOLTHYBG33P from .x_hybrid import XHybrid from .x1 import X1 +from .x1_hybrid_gen4 import X1HybridGen4 from .x1_mini import X1Mini from .x1_mini_v34 import X1MiniV34 from .x1_smart import X1Smart @@ -18,4 +19,5 @@ "X3V34", "X3", "X1Boost", + "X1HybridGen4", ] diff --git a/solax/inverters/qvolt_hyb_g3_3p.py b/solax/inverters/qvolt_hyb_g3_3p.py index 165e6ae..50cf0be 100644 --- a/solax/inverters/qvolt_hyb_g3_3p.py +++ b/solax/inverters/qvolt_hyb_g3_3p.py @@ -5,15 +5,10 @@ from solax.utils import ( div10, div100, + pack_u16, twoway_div10, to_signed, - pv_energy, twoway_div100, - total_energy, - discharge_energy, - charge_energy, - feedin_energy, - consumption, ) @@ -29,7 +24,7 @@ class Processors: """ @staticmethod - def inverter_modes(value, *_args, **_kwargs): + def inverter_modes(value): return { 0: "Waiting", 1: "Checking", @@ -45,7 +40,7 @@ def inverter_modes(value, *_args, **_kwargs): }.get(value, f"unmapped value '{value}'") @staticmethod - def battery_modes(value, *_args, **_kwargs): + def battery_modes(value): return { 0: "Self Use Mode", 1: "Force Time Use", @@ -121,24 +116,26 @@ def response_decoder(cls): # 53: always 0 # 54: follows PV Output, idles around 35, peaks at 54, # 55-67: always 0 - "Total Energy": (68, Total(Units.KWH), total_energy), - "Total Energy Resets": (69), + "Total Energy": (pack_u16(68, 69), Total(Units.KWH), div10), # 70: div10, today's energy including battery usage # 71-73: 0 - "Total Battery Discharge Energy": (74, Total(Units.KWH), discharge_energy), - "Total Battery Discharge Energy Resets": (75), - "Total Battery Charge Energy": (76, Total(Units.KWH), charge_energy), - "Total Battery Charge Energy Resets": (77), + "Total Battery Discharge Energy": ( + pack_u16(74, 75), + Total(Units.KWH), + div10, + ), + "Total Battery Charge Energy": ( + pack_u16(76, 77), + Total(Units.KWH), + div10, + ), "Today's Battery Discharge Energy": (78, Units.KWH, div10), "Today's Battery Charge Energy": (79, Units.KWH, div10), - "Total PV Energy": (80, Total(Units.KWH), pv_energy), - "Total PV Energy Resets": (81), + "Total PV Energy": (pack_u16(80, 81), Total(Units.KWH), div10), "Today's Energy": (82, Units.KWH, div10), # 83-85: always 0 - "Total Feed-in Energy": (86, Total(Units.KWH), feedin_energy), - "Total Feed-in Energy Resets": (87), - "Total Consumption": (88, Total(Units.KWH), consumption), - "Total Consumption Resets": (89), + "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), # 91: always 0 "Today's Consumption": (92, Units.KWH, div100), diff --git a/solax/inverters/x1_hybrid_gen4.py b/solax/inverters/x1_hybrid_gen4.py new file mode 100644 index 0000000..130b2c0 --- /dev/null +++ b/solax/inverters/x1_hybrid_gen4.py @@ -0,0 +1,50 @@ +import voluptuous as vol +from solax.inverter import InverterPostData +from solax.units import Units, Total +from solax.utils import div10, div100, pack_u16, to_signed + + +class X1HybridGen4(InverterPostData): + # pylint: disable=duplicate-code + _schema = vol.Schema( + { + vol.Required("type"): vol.All(int, 15), + vol.Required( + "sn", + ): str, + vol.Required("ver"): str, + 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))), + }, + extra=vol.REMOVE_EXTRA, + ) + + @classmethod + def response_decoder(cls): + return { + "AC voltage R": (0, Units.V, div10), + "AC current": (1, Units.A, div10), + "AC power": (2, Units.W), + "Grid frequency": (3, Units.HZ, div100), + "PV1 voltage": (4, Units.V, div10), + "PV2 voltage": (5, Units.V, div10), + "PV1 current": (6, Units.A, div10), + "PV2 current": (7, Units.A, div10), + "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), + "Battery voltage": (14, Units.V, div100), + "Battery current": (15, Units.A, div100), + "Battery power": (16, Units.W), + "Battery temperature": (17, Units.C), + "Battery SoC": (18, Units.PERCENT), + "Grid power": (32, Units.W, to_signed), + "Total feed-in energy": (pack_u16(34, 35), Total(Units.KWH), div100), + "Total consumption": (pack_u16(36, 37), Total(Units.KWH), div100), + } diff --git a/solax/inverters/x3_v34.py b/solax/inverters/x3_v34.py index 7d75bcc..515a6e3 100644 --- a/solax/inverters/x3_v34.py +++ b/solax/inverters/x3_v34.py @@ -4,16 +4,10 @@ from solax.utils import ( div10, div100, + pack_u16, twoway_div10, to_signed, - pv_energy, twoway_div100, - total_energy, - discharge_energy, - charge_energy, - feedin_energy, - consumption, - eps_total_energy, ) @@ -57,36 +51,37 @@ def response_decoder(cls): "PV2 Current": (12, Units.A, div10), "PV1 Power": (13, Units.W), "PV2 Power": (14, Units.W), - "Total PV Energy": (89, Total(Units.KWH), pv_energy), - "Total PV Energy Resets": (90), + "Total PV Energy": (pack_u16(89, 90), Total(Units.KWH), div10), "Today's PV Energy": (112, 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": (19, Total(Units.KWH), total_energy), - "Total Energy Resets": (20), + "Total Energy": (pack_u16(19, 20), Total(Units.KWH), div10), "Today's Energy": (21, Units.KWH, div10), "Battery Voltage": (24, Units.V, div100), "Battery Current": (25, Units.A, twoway_div100), "Battery Power": (26, Units.W, to_signed), "Battery Temperature": (27, Units.C), "Battery Remaining Capacity": (28, Units.PERCENT), - "Total Battery Discharge Energy": (30, Total(Units.KWH), discharge_energy), - "Total Battery Discharge Energy Resets": (31), + "Total Battery Discharge Energy": ( + pack_u16(30, 31), + Total(Units.KWH), + div10, + ), "Today's Battery Discharge Energy": (113, Units.KWH, div10), "Battery Remaining Energy": (32, Units.KWH, div10), - "Total Battery Charge Energy": (87, Total(Units.KWH), charge_energy), - "Total Battery Charge Energy Resets": (88), + "Total Battery Charge Energy": ( + pack_u16(87, 88), + Total(Units.KWH), + div10, + ), "Today's Battery Charge Energy": (114, Units.KWH, div10), "Exported Power": (65, Units.W, to_signed), - "Total Feed-in Energy": (67, Total(Units.KWH), feedin_energy), - "Total Feed-in Energy Resets": (68), - "Total Consumption": (69, Total(Units.KWH), consumption), - "Total Consumption Resets": (70), + "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": (110, Units.KWH, eps_total_energy), - "EPS Total Energy Resets": (111, Units.HZ), + "EPS Total Energy": (pack_u16(110, 111), Units.KWH, div10), } # pylint: enable=duplicate-code diff --git a/solax/utils.py b/solax/utils.py index c38b934..22851fe 100644 --- a/solax/utils.py +++ b/solax/utils.py @@ -1,87 +1,80 @@ +from typing import Protocol, Tuple from voluptuous import Invalid -def startswith(something): - def inner(actual): - if isinstance(actual, str): - if actual.startswith(something): - return actual - raise Invalid(f"{str(actual)} does not start with {something}") - - return inner +class Packer(Protocol): # pragma: no cover + # pylint: disable=R0903 + """ + Pack multiple raw values from the inverter + data into one raw value + """ - -def div10(val, *_args, **_kwargs): - return val / 10 + def __call__(self, *vals: float) -> float: + ... -def div100(val, *_args, **_kwargs): - return val / 100 +PackerBuilderResult = Tuple[Tuple[int, ...], Packer] -def resetting_counter(value, mapped_sensor_data, key, adjust, *_args, **_kwargs): - value += mapped_sensor_data[key] * 65535 - value = adjust(value) - return value +class PackerBuilder(Protocol): # pragma: no cover + # pylint: disable=R0903 + """ + Build a packer by identifying the indexes of the + raw values to be fed to the packer + """ + def __call__(self, *indexes: int) -> PackerBuilderResult: + ... -def total_energy(value, mapped_sensor_data, *_args, **_kwargs): - return resetting_counter( - value, mapped_sensor_data, key="Total Energy Resets", adjust=div10 - ) +def __u16_packer(*values: float) -> float: + accumulator = 0.0 + stride = 1 + for value in values: + accumulator += value * stride + stride *= 2**16 + return accumulator -def eps_total_energy(value, mapped_sensor_data, *_args, **_kwargs): - return resetting_counter( - value, mapped_sensor_data, key="EPS Total Energy Resets", adjust=div10 - ) +def pack_u16(*indexes: int) -> PackerBuilderResult: + """ + Some values are expressed over 2 (or potentially + more 16 bit [aka "short"] registers). Here we combine + them, in order of least to most significant. + """ + return (indexes, __u16_packer) -def feedin_energy(value, mapped_sensor_data, *_args, **_kwargs): - return resetting_counter( - value, mapped_sensor_data, key="Total Feed-in Energy Resets", adjust=div100 - ) +def startswith(something): + def inner(actual): + if isinstance(actual, str): + if actual.startswith(something): + return actual + raise Invalid(f"{str(actual)} does not start with {something}") -def charge_energy(value, mapped_sensor_data, *_args, **_kwargs): - return resetting_counter( - value, - mapped_sensor_data, - key="Total Battery Charge Energy Resets", - adjust=div10, - ) + return inner -def discharge_energy(value, mapped_sensor_data, *_args, **_kwargs): - return resetting_counter( - value, - mapped_sensor_data, - key="Total Battery Discharge Energy Resets", - adjust=div10, - ) +def div10(val): + return val / 10 -def pv_energy(value, mapped_sensor_data, *_args, **_kwargs): - return resetting_counter( - value, mapped_sensor_data, key="Total PV Energy Resets", adjust=div10 - ) +def div100(val): + return val / 100 -def consumption(value, mapped_sensor_data, *_args, **_kwargs): - return resetting_counter( - value, mapped_sensor_data, key="Total Consumption Resets", adjust=div100 - ) +INT16_MAX = 0x7FFF -def to_signed(val, *_args, **_kwargs): - if val > 32767: - val -= 65535 +def to_signed(val): + if val > INT16_MAX: + val -= 2**16 return val -def twoway_div10(val, *_args, **_kwargs): - return to_signed(val, None) / 10 +def twoway_div10(val): + return to_signed(val) / 10 -def twoway_div100(val, *_args, **_kwargs): - return to_signed(val, None) / 100 +def twoway_div100(val): + return to_signed(val) / 100 diff --git a/tests/fixtures.py b/tests/fixtures.py index c62b1a0..8d5cb51 100644 --- a/tests/fixtures.py +++ b/tests/fixtures.py @@ -14,12 +14,14 @@ X3V34_HYBRID_VALUES_NEGATIVE_POWER, XHYBRID_VALUES, X1_BOOST_VALUES, + X1_HYBRID_G4_VALUES, ) from tests.samples.responses import ( QVOLTHYBG33P_RESPONSE_V34, X1_BOOST_AIR_MINI_RESPONSE, X1_HYBRID_G3_2X_MPPT_RESPONSE, X1_HYBRID_G3_RESPONSE, + X1_HYBRID_G4_RESPONSE, X1_MINI_RESPONSE_V34, X1_SMART_RESPONSE, X3_HYBRID_G3_2X_MPPT_RESPONSE, @@ -46,7 +48,7 @@ def simple_http_fixture(httpserver): InverterUnderTest = namedtuple( "InverterUnderTest", - "uri, method, query_string, response, inverter, values, headers", + "uri, method, query_string, response, inverter, values, headers, data", ) INVERTERS_UNDER_TEST = [ @@ -58,6 +60,7 @@ def simple_http_fixture(httpserver): inverter=inverter.XHybrid, values=XHYBRID_VALUES, headers=None, + data=None, ), InverterUnderTest( uri="/api/realTimeData.htm", @@ -67,6 +70,17 @@ def simple_http_fixture(httpserver): inverter=inverter.XHybrid, values=XHYBRID_VALUES, headers=None, + data=None, + ), + InverterUnderTest( + uri="/", + method="POST", + query_string=None, + response=X1_HYBRID_G4_RESPONSE, + inverter=inverter.X1HybridGen4, + values=X1_HYBRID_G4_VALUES, + headers=None, + data="optType=ReadRealTimeData", ), InverterUnderTest( uri="/", @@ -76,6 +90,7 @@ def simple_http_fixture(httpserver): inverter=inverter.X1Mini, values=X1_MINI_VALUES, headers=None, + data=None, ), InverterUnderTest( uri="/", @@ -85,6 +100,7 @@ def simple_http_fixture(httpserver): inverter=inverter.X1MiniV34, values=X1_MINI_VALUES_V34, headers=None, + data=None, ), InverterUnderTest( uri="/", @@ -94,6 +110,7 @@ def simple_http_fixture(httpserver): inverter=inverter.X1Smart, values=X1_SMART_VALUES, headers=X_FORWARDED_HEADER, + data=None, ), InverterUnderTest( uri="/", @@ -103,6 +120,7 @@ def simple_http_fixture(httpserver): inverter=inverter.X1Boost, values=X1_BOOST_VALUES, headers=X_FORWARDED_HEADER, + data=None, ), InverterUnderTest( uri="/", @@ -112,6 +130,7 @@ def simple_http_fixture(httpserver): inverter=inverter.X3, values=X3_VALUES, headers=None, + data=None, ), InverterUnderTest( uri="/", @@ -121,6 +140,7 @@ def simple_http_fixture(httpserver): inverter=inverter.X3, values=X3_VALUES, headers=None, + data=None, ), InverterUnderTest( uri="/", @@ -130,6 +150,7 @@ def simple_http_fixture(httpserver): inverter=inverter.X1, values=X1_VALUES, headers=None, + data=None, ), InverterUnderTest( uri="/", @@ -139,6 +160,7 @@ def simple_http_fixture(httpserver): inverter=inverter.X1, values=X1_VALUES, headers=None, + data=None, ), InverterUnderTest( uri="/", @@ -148,6 +170,7 @@ def simple_http_fixture(httpserver): inverter=inverter.X3, values=X3_HYBRID_VALUES, headers=None, + data=None, ), InverterUnderTest( uri="/", @@ -157,6 +180,7 @@ def simple_http_fixture(httpserver): inverter=inverter.X3V34, values=X3V34_HYBRID_VALUES, headers=None, + data=None, ), InverterUnderTest( uri="/", @@ -166,6 +190,7 @@ def simple_http_fixture(httpserver): inverter=inverter.X3V34, values=X3V34_HYBRID_VALUES_NEGATIVE_POWER, headers=None, + data=None, ), InverterUnderTest( uri="/", @@ -175,6 +200,7 @@ def simple_http_fixture(httpserver): inverter=inverter.X3V34, values=X3V34_HYBRID_VALUES_EPS_MODE, headers=None, + data=None, ), InverterUnderTest( uri="/", @@ -184,6 +210,7 @@ def simple_http_fixture(httpserver): inverter=inverter.QVOLTHYBG33P, values=QVOLTHYBG33P_VALUES, headers=None, + data=None, ), ] @@ -200,6 +227,7 @@ def inverters_fixture(httpserver, request): method=request.param.method, query_string=request.param.query_string, headers=request.param.headers, + data=request.param.data, ).respond_with_json(request.param.response) yield ( (httpserver.host, httpserver.port), diff --git a/tests/samples/expected_values.py b/tests/samples/expected_values.py index 2e12d94..e9b24b0 100644 --- a/tests/samples/expected_values.py +++ b/tests/samples/expected_values.py @@ -117,13 +117,11 @@ "PV1 Power": 958, "PV2 Power": 0, "Total PV Energy": 1731.9, - "Total PV Energy Resets": 0, "Today's PV Energy": 16.4, "Grid Frequency Phase 1": 50.03, "Grid Frequency Phase 2": 50.03, "Grid Frequency Phase 3": 50.03, "Total Energy": 1483.3, - "Total Energy Resets": 0, "Today's Energy": 10.3, "Battery Voltage": 229.3, "Battery Current": 0.9, @@ -131,47 +129,40 @@ "Battery Temperature": 22, "Battery Remaining Capacity": 99, "Total Battery Discharge Energy": 706.2, - "Total Battery Discharge Energy Resets": 0, "Today's Battery Discharge Energy": 4.3, "Battery Remaining Energy": 12.5, "Total Battery Charge Energy": 814.2, - "Total Battery Charge Energy Resets": 0, "Today's Battery Charge Energy": 9.1, - "Exported Power": -55, + "Exported Power": -56, "Total Feed-in Energy": 173.72, - "Total Feed-in Energy Resets": 0, "Total Consumption": 598.77, - "Total Consumption Resets": 0, "AC Power": 686, "EPS Frequency": 0, "EPS Total Energy": 0.6, - "EPS Total Energy Resets": 0, } X3V34_HYBRID_VALUES_NEGATIVE_POWER = { "Network Voltage Phase 1": 236.4, "Network Voltage Phase 2": 243.1, "Network Voltage Phase 3": 238.6, - "Output Current Phase 1": -7.2, - "Output Current Phase 2": -7.1, - "Output Current Phase 3": -7.1, - "Power Now Phase 1": -1737, - "Power Now Phase 2": -1766, - "Power Now Phase 3": -1728, + "Output Current Phase 1": -7.3, + "Output Current Phase 2": -7.2, + "Output Current Phase 3": -7.2, + "Power Now Phase 1": -1738, + "Power Now Phase 2": -1767, + "Power Now Phase 3": -1729, "PV1 Voltage": 0, "PV2 Voltage": 0, "PV1 Current": 0, "PV2 Current": 0, "PV1 Power": 0, "PV2 Power": 0, - "Total PV Energy": 9615.1, - "Total PV Energy Resets": 1, + "Total PV Energy": 9615.2, "Today's PV Energy": 9.8, "Grid Frequency Phase 1": 49.98, "Grid Frequency Phase 2": 49.98, "Grid Frequency Phase 3": 49.98, - "Total Energy": 8478.6, - "Total Energy Resets": 1, + "Total Energy": 8478.7, "Today's Energy": 8.4, "Battery Voltage": 204.6, "Battery Current": 25, @@ -179,21 +170,16 @@ "Battery Temperature": 24, "Battery Remaining Capacity": 20, "Total Battery Discharge Energy": 2469.6, - "Total Battery Discharge Energy Resets": 0, "Today's Battery Discharge Energy": 4, "Battery Remaining Energy": 2.6, "Total Battery Charge Energy": 2887.6, - "Total Battery Charge Energy Resets": 0, "Today's Battery Charge Energy": 6, - "Exported Power": -5743, - "Total Feed-in Energy": 3593.49, - "Total Feed-in Energy Resets": 5, - "Total Consumption": 945.62, - "Total Consumption Resets": 1, - "AC Power": -5233, + "Exported Power": -5744, + "Total Feed-in Energy": 3593.54, + "Total Consumption": 945.63, + "AC Power": -5234, "EPS Frequency": 0, "EPS Total Energy": 2.1, - "EPS Total Energy Resets": 0, } X3V34_HYBRID_VALUES_EPS_MODE = { @@ -212,36 +198,29 @@ "PV2 Current": 0, "PV1 Power": 2678, "PV2 Power": 0, - "Total PV Energy": 8378.7, - "Total PV Energy Resets": 1, + "Total PV Energy": 8378.8, "Today's PV Energy": 26, "Grid Frequency Phase 1": 0, "Grid Frequency Phase 2": 0, "Grid Frequency Phase 3": 0, - "Total Energy": 7387.4, - "Total Energy Resets": 1, + "Total Energy": 7387.5, "Today's Energy": 17.8, "Battery Voltage": 228.6, - "Battery Current": -0.99, - "Battery Power": -241, + "Battery Current": -1.00, + "Battery Power": -242, "Battery Temperature": 27, "Battery Remaining Capacity": 98, "Total Battery Discharge Energy": 2124, - "Total Battery Discharge Energy Resets": 0, "Today's Battery Discharge Energy": 6.1, "Battery Remaining Energy": 12.4, "Total Battery Charge Energy": 2501.1, - "Total Battery Charge Energy Resets": 0, "Today's Battery Charge Energy": 11.9, "Exported Power": 0, - "Total Feed-in Energy": 3174.79, - "Total Feed-in Energy Resets": 4, - "Total Consumption": 844.57, - "Total Consumption Resets": 1, + "Total Feed-in Energy": 3174.83, + "Total Consumption": 844.58, "AC Power": 0, "EPS Frequency": 50, "EPS Total Energy": 1.7, - "EPS Total Energy Resets": 0, } X1_VALUES = { @@ -371,26 +350,20 @@ "Grid Frequency Phase 2": 50.01, "Grid Frequency Phase 3": 50.02, "Inverter Operation mode": "Normal", - "Exported Power": -6.0, + "Exported Power": -7.0, "Battery Voltage": 323.4, "Battery Current": 5.0, "Battery Power": 1616.0, "Power Now": 451.0, "Total Energy": 219.0, - "Total Energy Resets": 0.0, "Total Battery Discharge Energy": 73.8, - "Total Battery Discharge Energy Resets": 0.0, "Total Battery Charge Energy": 90.4, - "Total Battery Charge Energy Resets": 0.0, "Today's Battery Discharge Energy": 0.0, "Today's Battery Charge Energy": 8.1, "Total PV Energy": 231.6, - "Total PV Energy Resets": 0.0, "Today's Energy": 11.8, "Total Feed-in Energy": 107.94, - "Total Feed-in Energy Resets": 0.0, "Total Consumption": 145.44, - "Total Consumption Resets": 0.0, "Today's Feed-in Energy": 1.66, "Today's Consumption": 4.66, "Battery Remaining Capacity": 95.0, @@ -398,3 +371,26 @@ "Battery Remaining Energy": 8.8, "Battery Operation mode": "Self Use Mode", } + +X1_HYBRID_G4_VALUES = { + "AC voltage R": 247.0, + "AC current": 1.1, + "AC power": 237.0, + "Grid frequency": 49.86, + "PV1 voltage": 254.4, + "PV2 voltage": 259.1, + "PV1 current": 1.1, + "PV2 current": 1.2, + "PV1 power": 299.0, + "PV2 power": 329.0, + "On-grid total yield": 398.6, + "On-grid daily yield": 2.2, + "Battery voltage": 239.30, + "Battery current": 2.20, + "Battery power": 541.0, + "Battery temperature": 26.0, + "Battery SoC": 82.0, + "Grid power": 1.0, + "Total feed-in energy": 286.7, + "Total consumption": 6.2, +} diff --git a/tests/samples/responses.py b/tests/samples/responses.py index 30439e2..869d848 100644 --- a/tests/samples/responses.py +++ b/tests/samples/responses.py @@ -1215,6 +1215,227 @@ }, } + +X1_HYBRID_G4_RESPONSE = { + "type": 15, + "sn": "SXxxxxxxxx", + "ver": "3.003.02", + "Data": [ + 2470, + 11, + 237, + 4986, + 2544, + 2591, + 11, + 12, + 299, + 329, + 2, + 3986, + 0, + 22, + 23930, + 220, + 541, + 26, + 82, + 430, + 0, + 909, + 0, + 93, + 100, + 0, + 30, + 3833, + 0, + 0, + 0, + 0, + 1, + 0, + 28670, + 0, + 620, + 0, + 236, + 37, + 256, + 2628, + 1800, + 287, + 350, + 222, + 194, + 34, + 33, + 5, + 1, + 1, + 3, + 0, + 4334, + 0, + 65291, + 65535, + 65390, + 65535, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 5249, + 0, + 60312, + 65535, + 3000, + 0, + 62536, + 65535, + 10, + 0, + 10, + 0, + 0, + 0, + 0, + 10, + 15, + 14, + 0, + 0, + 20, + 0, + 40, + 0, + 0, + 0, + 0, + 0, + 0, + 15110, + 4360, + 5639, + 1106, + 520, + 8995, + 9252, + 0, + 0, + 0, + 0, + 1, + 2407, + 16, + 385, + 3354, + 3349, + 9355, + 2, + 21302, + 14389, + 18497, + 12355, + 16695, + 12355, + 14131, + 21302, + 14389, + 18497, + 12355, + 16695, + 12355, + 14131, + 21302, + 14389, + 18754, + 12340, + 16694, + 13123, + 12598, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 1, + 2562, + 259, + 1538, + 3, + 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": [ + 5.000, + 15, + "H450xxxxxxxxxx", + 8, + 1.24, + 0.00, + 1.21, + 1.03, + 0.00, + 1, + ], +} + X3_HYBRID_G3_RESPONSE = { "type": "X3-Hybiyd-G3", "SN": "XXXXXXX", diff --git a/tests/test_utils.py b/tests/test_utils.py new file mode 100644 index 0000000..b0193ee --- /dev/null +++ b/tests/test_utils.py @@ -0,0 +1,21 @@ +import struct +import pytest + +from solax import utils + + +@pytest.mark.parametrize( + "val_to_check", + [ + 0, + 1, + 0x7FFF, # int16 max + 0x8000, # - 2**15 + 0xFFFF, # -1 + ], +) +def test_to_signed(val_to_check): + # Take input as an int so can bit-twiddle reliably + actual = utils.to_signed(val_to_check) + expected = struct.unpack("