diff --git a/setup.py b/setup.py index 515608c..f50e90c 100644 --- a/setup.py +++ b/setup.py @@ -33,6 +33,7 @@ entry_points={ "solax.inverter": [ "qvolt_hyb_g3_3p = solax.inverters.qvolt_hyb_g3_3p:QVOLTHYBG33P", + "qvolt_hyb_g3_1p = solax.inverters.qvolt_hyb_g3_1p:QVOLTHYBG31P", "x1 = solax.inverters.x1:X1", "x1_boost = solax.inverters.x1_boost:X1Boost", "x1_hybrid_gen4 = solax.inverters.x1_hybrid_gen4:X1HybridGen4", diff --git a/solax/inverters/__init__.py b/solax/inverters/__init__.py index 883e9d3..d740c76 100644 --- a/solax/inverters/__init__.py +++ b/solax/inverters/__init__.py @@ -1,3 +1,4 @@ +from .qvolt_hyb_g3_1p import QVOLTHYBG31P from .qvolt_hyb_g3_3p import QVOLTHYBG33P from .x1 import X1 from .x1_boost import X1Boost @@ -13,6 +14,7 @@ __all__ = [ "QVOLTHYBG33P", + "QVOLTHYBG31P", "XHybrid", "X1", "X1Mini", diff --git a/solax/inverters/qvolt_hyb_g3_1p.py b/solax/inverters/qvolt_hyb_g3_1p.py new file mode 100644 index 0000000..54899dd --- /dev/null +++ b/solax/inverters/qvolt_hyb_g3_1p.py @@ -0,0 +1,172 @@ +from typing import Any, Dict, Optional + +import voluptuous as vol + +from solax.inverter import Inverter, InverterHttpClient +from solax.units import DailyTotal, Measurement, Total, Units +from solax.utils import ( + div10, + div100, + div1000, + pack_u16, + to_signed, + twoway_div10, + twoway_div100, +) + + +class QVOLTHYBG31P(Inverter): + """ + QCells + Q.VOLT HYB-G3-1P + """ + + class Processors: + """ + Postprocessors used only in the QVOLTHYBG31P inverter sensor_map. + """ + + # pylint: disable=duplicate-code + @staticmethod + def inverter_modes(value): + return { + 0: "Waiting", + 1: "Checking", + 2: "Normal", + 3: "Off", + 4: "Permanent Fault", + 5: "Updating", + 6: "EPS Check", + 7: "EPS Mode", + 8: "Self Test", + 9: "Idle", + 10: "Standby", + }.get(value, f"unmapped value '{value}'") + + # pylint: disable=duplicate-code + @staticmethod + def battery_modes(value): + return { + 0: "Self Use Mode", + 1: "Force Time Use", + 2: "Back Up Mode", + 3: "Feed-in Priority", + }.get(value, f"unmapped value '{value}'") + + def __init__(self, http_client: InverterHttpClient, *args, **kwargs): + super().__init__(http_client, *args, **kwargs) + self.manufacturer = "Qcells" + + # 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=10, max=10)) + ), + }, + extra=vol.REMOVE_EXTRA, + ) + + @classmethod + def response_decoder(cls): + return { + "Network Voltage": (0, Units.V, div10), + "Inverter output current": (1, Units.A, twoway_div10), + "Inverter output power": (2, Units.W, to_signed), + "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), + "Inverter status": (10, Units.NONE, cls.Processors.inverter_modes), + "Total inverter yield": (pack_u16(11, 12), Total(Units.KWH), div10), + "Today's inverter yield": (13, DailyTotal(Units.KWH), div10), + "Battery Voltage": (14, Units.V, div100), + "Battery Current": (15, Units.A, twoway_div100), + "Battery Power": (16, Units.W, to_signed), + "Battery Temperature": (17, Units.C), + "Battery Remaining Capacity": (18, Units.PERCENT), + "Total battery discharged energy": ( + pack_u16(19, 20), + Total(Units.KWH), + div10, + ), + "Total battery charged energy": (pack_u16(21, 22), Total(Units.KWH), div10), + "Battery Remaining Energy": ( + 23, + Measurement(Units.KWH, storage=True), + div10, + ), + # 24: always 100. probably 10.0%, minimum charge left before switching to grid + # 25: always 0 + # 26-27: jumping around + # 28-31: always 0 + "Current grid power": (32, Units.W, to_signed), + # 33: if [32] > 32767 : 65535 ? 0 + "Total grid export": (pack_u16(34, 35), Total(Units.KWH), div100), + "Total grid import": (pack_u16(36, 37), Total(Units.KWH), div100), + "Current power usage": (38, Units.W, to_signed), + # 39: observed range 0-38. something about the inverter + # 40: 256 when inverter is running, 0 when it's idle + # 41-42: fixed values + # 43-49: no idea, varying values + # 50-51: always 1. possibly an inverter setting + # 52: idk, max 4, min 0 + # 53: always 0 + "Total self-used solar": (pack_u16(54, 55), Total(Units.KWH), div10), + # 56-57: 32 bit pack? mostly negative, went positive briefly + # 58-59: 32 bit pack? always negative, between -144 and -175 + # 60-69: always 0 + # 70: fluctuates quite wildly + # 72-73: 32 bit pack? + # 74-77: fixed values + "Today's grid export": (pack_u16(78, 79), DailyTotal(Units.KWH), div100), + "Today's grid import": (pack_u16(80, 81), DailyTotal(Units.KWH), div100), + # 82-84: always 0 + "Today's solar yield": (85, DailyTotal(Units.KWH), div10), + # 86: something daily + # 87: something else daily + # 88-89: always 0 + # 90: min 350, max 360 + # 91: always 0 + # 92: always 230 + # 93-95: something daily + # 97-98: always 0 + # 99-100: weird counter. rtc? + # 101-109: fixed values + # 110: battery type!? + # 111-115: mysterious stuff + "Total battery energy throughput": ( + pack_u16(116, 117), + Measurement(Units.KWH, storage=True), + div1000, + ), + # 118-124: BMS serial number, ASCII, little-endian, 2 chars per register + # 125-131: Battery 1 serial number, ASCII, little-endian, 2 chars per register + # 132-138: Battery 2 serial number, ASCII, little-endian, 2 chars per register + # 139-145: Battery 3 serial number, ASCII, little-endian, 2 chars per register + # 146-152: Battery 4 serial number, ASCII, little-endian, 2 chars per register + # 153-156: ??? + "Battery Operation mode": (157, Units.NONE, cls.Processors.battery_modes), + # 158 - 199: always 0 + } + + @classmethod + 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, False)] + return versions diff --git a/solax/inverters/qvolt_hyb_g3_3p.py b/solax/inverters/qvolt_hyb_g3_3p.py index 5bda4ee..0194c8c 100644 --- a/solax/inverters/qvolt_hyb_g3_3p.py +++ b/solax/inverters/qvolt_hyb_g3_3p.py @@ -18,6 +18,7 @@ class Processors: Postprocessors used only in the QVOLTHYBG33P inverter sensor_map. """ + # pylint: disable=duplicate-code @staticmethod def inverter_modes(value): return { @@ -34,6 +35,7 @@ def inverter_modes(value): 10: "Standby", }.get(value, f"unmapped value '{value}'") + # pylint: disable=duplicate-code @staticmethod def battery_modes(value): return { @@ -47,6 +49,7 @@ def __init__(self, http_client: InverterHttpClient, *args, **kwargs): super().__init__(http_client, *args, **kwargs) self.manufacturer = "Qcells" + # pylint: disable=duplicate-code _schema = vol.Schema( { vol.Required("type"): vol.All(int, 14), diff --git a/solax/units.py b/solax/units.py index eab9cbf..58cd0a8 100644 --- a/solax/units.py +++ b/solax/units.py @@ -9,6 +9,7 @@ class Units(Enum): W = "W" KWH = "kWh" + WH = "Wh" A = "A" V = "V" C = "°C" diff --git a/solax/utils.py b/solax/utils.py index f2a884f..3d29155 100644 --- a/solax/utils.py +++ b/solax/utils.py @@ -62,6 +62,10 @@ def div100(val): return val / 100 +def div1000(val): + return val / 1000 + + INT16_MAX = 0x7FFF INT32_MAX = 0x7FFFFFFF diff --git a/tests/fixtures.py b/tests/fixtures.py index 3983bfb..4a05d66 100644 --- a/tests/fixtures.py +++ b/tests/fixtures.py @@ -4,6 +4,7 @@ import solax.inverters as inverter from tests.samples.expected_values import ( + QVOLTHYBG31P_VALUES, QVOLTHYBG33P_VALUES, X1_BOOST_VALUES, X1_BOOST_VALUES_OVERFLOWN, @@ -23,6 +24,7 @@ XHYBRID_VALUES, ) from tests.samples.responses import ( + QVOLTHYBG31P_RESPONSE, QVOLTHYBG33P_RESPONSE_V34, X1_BOOST_AIR_MINI_RESPONSE, X1_BOOST_RESPONSE, @@ -262,6 +264,16 @@ def simple_http_fixture(httpserver): headers=None, data="optType=ReadRealTimeData", ), + InverterUnderTest( + uri="/", + method="POST", + query_string="", + response=QVOLTHYBG31P_RESPONSE, + inverter=inverter.QVOLTHYBG31P, + values=QVOLTHYBG31P_VALUES, + headers=None, + data=None, + ), ] diff --git a/tests/samples/expected_values.py b/tests/samples/expected_values.py index 4af3422..532d752 100644 --- a/tests/samples/expected_values.py +++ b/tests/samples/expected_values.py @@ -521,3 +521,37 @@ "Total feed-in energy": 286.7, "Total consumption": 6.2, } + +QVOLTHYBG31P_VALUES = { + "Network Voltage": 238.1, + "Inverter output current": 10.0, + "Inverter output power": 2372, + "Grid Frequency": 50.04, + "PV1 Voltage": 241.5, + "PV2 Voltage": 240.3, + "PV1 Current": 7.6, + "PV2 Current": 7.7, + "PV1 Power": 1839, + "PV2 Power": 1862, + "Inverter status": "Normal", + "Total inverter yield": 1925.2, + "Today's inverter yield": 0.6, + "Battery Voltage": 322.80, + "Battery Current": 3.8, + "Battery Power": 1252, + "Battery Temperature": 25, + "Battery Remaining Capacity": 96, + "Total battery discharged energy": 331.6, + "Total battery charged energy": 444.6, + "Battery Remaining Energy": 8.9, + "Current grid power": -180, + "Total grid export": 1179.00, + "Total grid import": 16.83, + "Current power usage": 940, + "Total self-used solar": 2105.0, + "Today's grid export": 0.10, + "Today's grid import": 1.63, + "Today's solar yield": 8.3, + "Total battery energy throughput": 1398.396, + "Battery Operation mode": "Feed-in Priority", +} diff --git a/tests/samples/responses.py b/tests/samples/responses.py index 0e4078f..f862f2d 100644 --- a/tests/samples/responses.py +++ b/tests/samples/responses.py @@ -3239,3 +3239,212 @@ ], "Information": [12.0, 14, "H34XXXXXXXX", 1, 1.15, 0.0, 1.14, 1.07, 0.0, 1], } + +QVOLTHYBG31P_RESPONSE = { + "sn": "SXKXXXXXXX", + "ver": "3.001.03", + "type": 15, + "Data": [ + 2381, + 100, + 2372, + 5004, + 2415, + 2403, + 76, + 77, + 1839, + 1862, + 2, + 19252, + 0, + 6, + 32280, + 380, + 1252, + 25, + 96, + 3316, + 0, + 4446, + 0, + 89, + 100, + 0, + 29, + 3688, + 0, + 0, + 0, + 0, + 65356, + 65536, + 52364, + 1, + 1683, + 0, + 940, + 34, + 256, + 3504, + 2400, + 78, + 300, + 257, + 234, + 35, + 34, + 86, + 1, + 1, + 4, + 0, + 21050, + 0, + 63163, + 65535, + 65386, + 65535, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 2644, + 0, + 60659, + 65535, + 3600, + 0, + 61936, + 65535, + 10, + 0, + 163, + 0, + 0, + 0, + 0, + 83, + 0, + 79, + 0, + 0, + 360, + 0, + 230, + 0, + 0, + 0, + 0, + 0, + 0, + 13588, + 1035, + 5643, + 1620, + 778, + 14135, + 14135, + 14135, + 0, + 0, + 0, + 1, + 3253, + 34, + 1106, + 3490, + 3384, + 22140, + 21, + 20564, + 12339, + 18497, + 12866, + 18736, + 12356, + 13105, + 20564, + 12339, + 18498, + 12355, + 18740, + 12356, + 13873, + 20564, + 12339, + 18498, + 12866, + 18740, + 12356, + 12849, + 20564, + 12339, + 18754, + 12849, + 18742, + 13124, + 12338, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 1, + 4099, + 5633, + 1026, + 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": [6.000, 15, "H460XXXXXXXXXX", 8, 1.29, 0.00, 1.27, 1.03, 0.00, 1], +}