diff --git a/solax/__init__.py b/solax/__init__.py index 2c01472..3b5827c 100644 --- a/solax/__init__.py +++ b/solax/__init__.py @@ -6,6 +6,7 @@ import async_timeout from solax.discovery import discover +from solax.inverter import Inverter, InverterResponse _LOGGER = logging.getLogger(__name__) @@ -13,7 +14,7 @@ REQUEST_TIMEOUT = 5 -async def rt_request(inv, retry, t_wait=0): +async def rt_request(inv: Inverter, retry, t_wait=0) -> InverterResponse: """Make call to inverter endpoint.""" if t_wait > 0: msg = "Timeout connecting to Solax inverter, waiting %d to retry." @@ -41,10 +42,10 @@ class RealTimeAPI: # pylint: disable=too-few-public-methods - def __init__(self, inv): + def __init__(self, inv: Inverter): """Initialize the API client.""" self.inverter = inv - async def get_data(self): + async def get_data(self) -> InverterResponse: """Query the real time API""" return await rt_request(self.inverter, 3) diff --git a/solax/inverter.py b/solax/inverter.py index 5290665..1290332 100644 --- a/solax/inverter.py +++ b/solax/inverter.py @@ -1,11 +1,13 @@ from collections import namedtuple import json -from typing import Any, Callable, Tuple, Union, Dict +from typing import Dict, Any, Callable, Tuple, Union import aiohttp import voluptuous as vol from voluptuous import Invalid, MultipleInvalid from voluptuous.humanize import humanize_error +from solax.units import Measurement, SensorUnit, Units + class InverterError(Exception): """Indicates error communicating with inverter""" @@ -18,8 +20,9 @@ class Inverter: """Base wrapper around Inverter HTTP API""" ResponseDecoderType = Union[ - Dict[str, Tuple[int, str]], - Dict[str, Tuple[int, str, Callable[[Any, Any, Any], Any]]], + Dict[str, int], + Dict[str, Tuple[int, SensorUnit]], + Dict[str, Tuple[int, SensorUnit, Callable[[Any, Any], Any]]], ] @classmethod @@ -39,7 +42,7 @@ def __init__(self, host, port, pwd=""): self.pwd = pwd self.manufacturer = "Solax" - async def get_data(self): + async def get_data(self) -> InverterResponse: try: data = await self.make_request(self.host, self.port, self.pwd) except aiohttp.ClientError as ex: @@ -54,7 +57,7 @@ async def get_data(self): return data @classmethod - async def make_request(cls, host, port, pwd="", headers=None): + async def make_request(cls, host, port, pwd="", headers=None) -> InverterResponse: """ Return instance of 'InverterResponse' Raise exception if unable to get data @@ -62,24 +65,38 @@ async def make_request(cls, host, port, pwd="", headers=None): raise NotImplementedError() @classmethod - def sensor_map(cls): + def sensor_map(cls) -> Dict[str, Tuple[int, Measurement]]: """ Return sensor map """ sensors = {} - for name, (idx, unit, *_) in cls.response_decoder().items(): + for name, mapping in cls.response_decoder().items(): + unit = Measurement(Units.NONE) + + if isinstance(mapping, tuple): + (idx, unit_or_measurement, *_) = mapping + else: + idx = mapping + + if isinstance(unit_or_measurement, Units): + unit = Measurement(unit_or_measurement) + else: + unit = unit_or_measurement sensors[name] = (idx, unit) return sensors @classmethod - def postprocess_map(cls): + def postprocess_map(cls) -> Dict[str, Callable[[Any, Any], Any]]: """ Return map of functions to be applied to each sensor value """ sensors = {} - for name, (_, _, *processor) in cls.response_decoder().items(): - if processor: - sensors[name] = processor[0] + for name, mapping in cls.response_decoder().items(): + if isinstance(mapping, tuple): + processor = None + (_, _, *processor) = mapping + if processor: + sensors[name] = processor[0] return sensors @classmethod @@ -90,7 +107,7 @@ def schema(cls) -> vol.Schema: return cls._schema @classmethod - def map_response(cls, resp_data): + def map_response(cls, resp_data) -> Dict[str, Any]: result = {} for sensor_name, (idx, _) in cls.sensor_map().items(): val = resp_data[idx] @@ -119,12 +136,12 @@ async def make_request(cls, host, port=80, pwd="", headers=None): return cls.handle_response(resp) @classmethod - def handle_response(cls, resp): + def handle_response(cls, resp: bytearray): """ Decode response and map array result using mapping definition. Args: - resp (_type_): The response + resp (bytearray): The response Returns: InverterResponse: The decoded and mapped interver response. diff --git a/solax/inverters/qvolt_hyb_g3_3p.py b/solax/inverters/qvolt_hyb_g3_3p.py index 2900cd6..165e6ae 100644 --- a/solax/inverters/qvolt_hyb_g3_3p.py +++ b/solax/inverters/qvolt_hyb_g3_3p.py @@ -1,6 +1,7 @@ import voluptuous as vol import aiohttp from solax.inverter import InverterPost +from solax.units import Total, Units from solax.utils import ( div10, div100, @@ -77,42 +78,42 @@ def __init__(self, host, port, pwd=""): @classmethod def response_decoder(cls): return { - "Network Voltage Phase 1": (0, "V", div10), - "Network Voltage Phase 2": (1, "V", div10), - "Network Voltage Phase 3": (2, "V", div10), - "Output Current Phase 1": (3, "A", twoway_div10), - "Output Current Phase 2": (4, "A", twoway_div10), - "Output Current Phase 3": (5, "A", twoway_div10), - "Power Now Phase 1": (6, "W", to_signed), - "Power Now Phase 2": (7, "W", to_signed), - "Power Now Phase 3": (8, "W", to_signed), - "AC Power": (9, "W", to_signed), - "PV1 Voltage": (10, "V", div10), - "PV2 Voltage": (11, "V", div10), - "PV1 Current": (12, "A", div10), - "PV2 Current": (13, "A", div10), - "PV1 Power": (14, "W"), - "PV2 Power": (15, "W"), - "Grid Frequency Phase 1": (16, "Hz", div100), - "Grid Frequency Phase 2": (17, "Hz", div100), - "Grid Frequency Phase 3": (18, "Hz", div100), - "Inverter Operation mode": (19, "", cls.Processors.inverter_modes), + "Network Voltage Phase 1": (0, Units.V, div10), + "Network Voltage Phase 2": (1, Units.V, div10), + "Network Voltage Phase 3": (2, Units.V, div10), + "Output Current Phase 1": (3, Units.A, twoway_div10), + "Output Current Phase 2": (4, Units.A, twoway_div10), + "Output Current Phase 3": (5, Units.A, twoway_div10), + "Power Now Phase 1": (6, Units.W, to_signed), + "Power Now Phase 2": (7, Units.W, to_signed), + "Power Now Phase 3": (8, Units.W, to_signed), + "AC Power": (9, Units.W, to_signed), + "PV1 Voltage": (10, Units.V, div10), + "PV2 Voltage": (11, Units.V, div10), + "PV1 Current": (12, Units.A, div10), + "PV2 Current": (13, Units.A, div10), + "PV1 Power": (14, Units.W), + "PV2 Power": (15, Units.W), + "Grid Frequency Phase 1": (16, Units.HZ, div100), + "Grid Frequency Phase 2": (17, Units.HZ, div100), + "Grid Frequency Phase 3": (18, Units.HZ, div100), + "Inverter Operation mode": (19, Units.NONE, cls.Processors.inverter_modes), # 20 - 32: always 0 # 33: always 1 # instead of to_signed this is actually 34 - 35, # because 35 = if 34>32767: 0 else: 65535 - "Exported Power": (34, "W", to_signed), + "Exported Power": (34, Units.W, to_signed), # 35: if 34>32767: 0 else: 65535 # 36 - 38 : always 0 - "Battery Voltage": (39, "V", div100), - "Battery Current": (40, "A", twoway_div100), - "Battery Power": (41, "W", to_signed), + "Battery Voltage": (39, Units.V, div100), + "Battery Current": (40, Units.A, twoway_div100), + "Battery Power": (41, Units.W, to_signed), # 42: div10, almost identical to [39] # 43: twoway_div10, almost the same as "40" (battery current) # 44: twoway_div100, almost the same as "41" (battery power), # 45: always 1 # 46: follows PV Output, idles around 44, peaks at 52, - "Power Now": (47, "W", to_signed), + "Power Now": (47, Units.W, to_signed), # 48: always 256 # 49,50: [49] + [50] * 15160 some increasing counter # 51: always 5634 @@ -120,33 +121,33 @@ def response_decoder(cls): # 53: always 0 # 54: follows PV Output, idles around 35, peaks at 54, # 55-67: always 0 - "Total Energy": (68, "kWh", total_energy), - "Total Energy Resets": (69, ""), + "Total Energy": (68, Total(Units.KWH), total_energy), + "Total Energy Resets": (69), # 70: div10, today's energy including battery usage # 71-73: 0 - "Total Battery Discharge Energy": (74, "kWh", discharge_energy), - "Total Battery Discharge Energy Resets": (75, ""), - "Total Battery Charge Energy": (76, "kWh", charge_energy), - "Total Battery Charge Energy Resets": (77, ""), - "Today's Battery Discharge Energy": (78, "kWh", div10), - "Today's Battery Charge Energy": (79, "kWh", div10), - "Total PV Energy": (80, "kWh", pv_energy), - "Total PV Energy Resets": (81, ""), - "Today's Energy": (82, "kWh", div10), + "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), + "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), + "Today's Energy": (82, Units.KWH, div10), # 83-85: always 0 - "Total Feed-in Energy": (86, "kWh", feedin_energy), - "Total Feed-in Energy Resets": (87, ""), - "Total Consumption": (88, "kWh", consumption), - "Total Consumption Resets": (89, ""), - "Today's Feed-in Energy": (90, "kWh", div100), + "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), + "Today's Feed-in Energy": (90, Units.KWH, div100), # 91: always 0 - "Today's Consumption": (92, "kWh", div100), + "Today's Consumption": (92, Units.KWH, div100), # 93-101: always 0 # 102: always 1 - "Battery Remaining Capacity": (103, "%"), + "Battery Remaining Capacity": (103, Units.PERCENT), # 104: always 1 - "Battery Temperature": (105, "C"), - "Battery Remaining Energy": (106, "kWh", div10), + "Battery Temperature": (105, Units.C), + "Battery Remaining Energy": (106, Units.KWH, div10), # 107: always 256 or 0 # 108: always 3504 # 109: always 2400 @@ -161,7 +162,7 @@ def response_decoder(cls): # with offset around 15 # 127,128 resetting counter /1000, around battery charge + discharge # 164,165,166 some curves - "Battery Operation mode": (168, "", cls.Processors.battery_modes), + "Battery Operation mode": (168, Units.NONE, cls.Processors.battery_modes), # 169: div100 same as [39] # 170-199: always 0 } diff --git a/solax/inverters/x1.py b/solax/inverters/x1.py index 0f08ff8..5e65bb5 100644 --- a/solax/inverters/x1.py +++ b/solax/inverters/x1.py @@ -1,5 +1,6 @@ import voluptuous as vol from solax.inverter import InverterPost +from solax.units import Units, Total from solax.utils import startswith @@ -28,32 +29,32 @@ class X1(InverterPost): @classmethod def response_decoder(cls): return { - "PV1 Current": (0, "A"), - "PV2 Current": (1, "A"), - "PV1 Voltage": (2, "V"), - "PV2 Voltage": (3, "V"), - "Output Current": (4, "A"), - "Network Voltage": (5, "V"), - "AC Power": (6, "W"), - "Inverter Temperature": (7, "C"), - "Today's Energy": (8, "kWh"), - "Total Energy": (9, "kWh"), - "Exported Power": (10, "W"), - "PV1 Power": (11, "W"), - "PV2 Power": (12, "W"), - "Battery Voltage": (13, "V"), - "Battery Current": (14, "A"), - "Battery Power": (15, "W"), - "Battery Temperature": (16, "C"), - "Battery Remaining Capacity": (21, "%"), - "Total Feed-in Energy": (41, "kWh"), - "Total Consumption": (42, "kWh"), - "Power Now": (43, "W"), - "Grid Frequency": (50, "Hz"), - "EPS Voltage": (53, "V"), - "EPS Current": (54, "A"), - "EPS Power": (55, "W"), - "EPS Frequency": (56, "Hz"), + "PV1 Current": (0, Units.A), + "PV2 Current": (1, Units.A), + "PV1 Voltage": (2, Units.V), + "PV2 Voltage": (3, Units.V), + "Output Current": (4, Units.A), + "Network Voltage": (5, Units.V), + "AC Power": (6, Units.W), + "Inverter Temperature": (7, Units.C), + "Today's Energy": (8, Units.KWH), + "Total Energy": (9, Total(Units.KWH)), + "Exported Power": (10, Units.W), + "PV1 Power": (11, Units.W), + "PV2 Power": (12, Units.W), + "Battery Voltage": (13, Units.V), + "Battery Current": (14, Units.A), + "Battery Power": (15, Units.W), + "Battery Temperature": (16, Units.C), + "Battery Remaining Capacity": (21, Units.PERCENT), + "Total Feed-in Energy": (41, Total(Units.KWH)), + "Total Consumption": (42, Total(Units.KWH)), + "Power Now": (43, Units.W), + "Grid Frequency": (50, Units.HZ), + "EPS Voltage": (53, Units.V), + "EPS Current": (54, Units.A), + "EPS Power": (55, Units.W), + "EPS Frequency": (56, Units.HZ), } # pylint: enable=duplicate-code diff --git a/solax/inverters/x1_boost.py b/solax/inverters/x1_boost.py index 43f4692..ef6a29f 100644 --- a/solax/inverters/x1_boost.py +++ b/solax/inverters/x1_boost.py @@ -1,5 +1,6 @@ import voluptuous as vol from solax.inverter import InverterPost +from solax.units import Units, Total from solax.utils import div10, div100, to_signed @@ -33,22 +34,22 @@ class X1Boost(InverterPost): @classmethod def response_decoder(cls): return { - "AC Voltage": (0, "V", div10), - "AC Output Current": (1, "A", div10), - "AC Output Power": (2, "W"), - "PV1 Voltage": (3, "V", div10), - "PV2 Voltage": (4, "V", div10), - "PV1 Current": (5, "A", div10), - "PV2 Current": (6, "A", div10), - "PV1 Power": (7, "W"), - "PV2 Power": (8, "W"), - "AC Frequency": (9, "Hz", div100), - "Total Generated Energy": (11, "kWh", div10), - "Today's Generated Energy": (13, "kWh", div10), - "Inverter Temperature": (39, "C"), - "Exported Power": (48, "W", to_signed), - "Total Export Energy": (50, "kWh", div100), - "Total Import Energy": (52, "kWh", div100), + "AC Voltage": (0, Units.V, div10), + "AC Output Current": (1, Units.A, div10), + "AC Output Power": (2, Units.W), + "PV1 Voltage": (3, Units.V, div10), + "PV2 Voltage": (4, Units.V, div10), + "PV1 Current": (5, Units.A, div10), + "PV2 Current": (6, Units.A, div10), + "PV1 Power": (7, Units.W), + "PV2 Power": (8, Units.W), + "AC Frequency": (9, Units.HZ, div100), + "Total Generated Energy": (11, 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), } @classmethod diff --git a/solax/inverters/x1_mini.py b/solax/inverters/x1_mini.py index 5e8dbfc..950b400 100644 --- a/solax/inverters/x1_mini.py +++ b/solax/inverters/x1_mini.py @@ -1,5 +1,6 @@ import voluptuous as vol from solax.inverter import InverterPost +from solax.units import Total, Units from solax.utils import startswith @@ -24,23 +25,23 @@ class X1Mini(InverterPost): @classmethod def response_decoder(cls): return { - "PV1 Current": (0, "A"), - "PV2 Current": (1, "A"), - "PV1 Voltage": (2, "V"), - "PV2 Voltage": (3, "V"), - "Output Current": (4, "A"), - "Network Voltage": (5, "V"), - "AC Power": (6, "W"), - "Inverter Temperature": (7, "C"), - "Today's Energy": (8, "kWh"), - "Total Energy": (9, "kWh"), - "Exported Power": (10, "W"), - "PV1 Power": (11, "W"), - "PV2 Power": (12, "W"), - "Total Feed-in Energy": (41, "kWh"), - "Total Consumption": (42, "kWh"), - "Power Now": (43, "W"), - "Grid Frequency": (50, "Hz"), + "PV1 Current": (0, Units.A), + "PV2 Current": (1, Units.A), + "PV1 Voltage": (2, Units.V), + "PV2 Voltage": (3, Units.V), + "Output Current": (4, Units.A), + "Network Voltage": (5, Units.V), + "AC Power": (6, Units.W), + "Inverter Temperature": (7, Units.C), + "Today's Energy": (8, Units.KWH), + "Total Energy": (9, Total(Units.KWH)), + "Exported Power": (10, Units.W), + "PV1 Power": (11, Units.W), + "PV2 Power": (12, Units.W), + "Total Feed-in Energy": (41, Total(Units.KWH)), + "Total Consumption": (42, Total(Units.KWH)), + "Power Now": (43, Units.W), + "Grid Frequency": (50, Units.HZ), } # pylint: enable=duplicate-code diff --git a/solax/inverters/x1_mini_v34.py b/solax/inverters/x1_mini_v34.py index 12d4f69..9a02518 100644 --- a/solax/inverters/x1_mini_v34.py +++ b/solax/inverters/x1_mini_v34.py @@ -1,6 +1,7 @@ import voluptuous as vol from solax.inverter import InverterPost +from solax.units import Units, Total from solax.utils import div10, div100 @@ -40,22 +41,22 @@ class X1MiniV34(InverterPost): @classmethod def response_decoder(cls): return { - "Network Voltage": (0, "V", div10), - "Output Current": (1, "A", div10), - "AC Power": (2, "W"), - "PV1 Voltage": (3, "V", div10), - "PV2 Voltage": (4, "V", div10), - "PV1 Current": (5, "A", div10), - "PV2 Current": (6, "A", div10), - "PV1 Power": (7, "W"), - "PV2 Power": (8, "W"), - "Grid Frequency": (9, "Hz", div100), - "Total Energy": (11, "kWh", div10), - "Today's Energy": (13, "kWh", div10), - "Total Feed-in Energy": (41, "kWh", div10), - "Total Consumption": (42, "kWh", div10), - "Power Now": (43, "W", div10), - "Inverter Temperature": (55, "C"), + "Network Voltage": (0, Units.V, div10), + "Output Current": (1, Units.A, div10), + "AC Power": (2, Units.W), + "PV1 Voltage": (3, Units.V, div10), + "PV2 Voltage": (4, Units.V, div10), + "PV1 Current": (5, Units.A, div10), + "PV2 Current": (6, Units.A, div10), + "PV1 Power": (7, Units.W), + "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), + "Total Feed-in Energy": (41, Total(Units.KWH), div10), + "Total Consumption": (42, Total(Units.KWH), div10), + "Power Now": (43, Units.W, div10), + "Inverter Temperature": (55, Units.C), } # pylint: enable=duplicate-code diff --git a/solax/inverters/x1_smart.py b/solax/inverters/x1_smart.py index b5af1b5..7a328d4 100644 --- a/solax/inverters/x1_smart.py +++ b/solax/inverters/x1_smart.py @@ -1,5 +1,6 @@ import voluptuous as vol from solax.inverter import InverterPost +from solax.units import Total, Units from solax.utils import div10, div100, to_signed @@ -31,22 +32,22 @@ class X1Smart(InverterPost): @classmethod def response_decoder(cls): return { - "Network Voltage": (0, "V", div10), - "Output Current": (1, "A", div10), - "AC Power": (2, "W"), - "PV1 Voltage": (3, "V", div10), - "PV2 Voltage": (4, "V", div10), - "PV1 Current": (5, "A", div10), - "PV2 Current": (6, "A", div10), - "PV1 Power": (7, "W"), - "PV2 Power": (8, "W"), - "Grid Frequency": (9, "Hz", div100), - "Total Energy": (11, "kWh", div10), - "Today's Energy": (13, "kWh", div10), - "Inverter Temperature": (39, "C"), - "Exported Power": (48, "W", to_signed), - "Total Feed-in Energy": (50, "kWh", div100), - "Total Consumption": (52, "kWh", div100), + "Network Voltage": (0, Units.V, div10), + "Output Current": (1, Units.A, div10), + "AC Power": (2, Units.W), + "PV1 Voltage": (3, Units.V, div10), + "PV2 Voltage": (4, Units.V, div10), + "PV1 Current": (5, Units.A, div10), + "PV2 Current": (6, Units.A, div10), + "PV1 Power": (7, Units.W), + "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), + "Inverter Temperature": (39, Units.C), + "Exported Power": (48, Units.W, to_signed), + "Total Feed-in Energy": (50, Total(Units.KWH), div100), + "Total Consumption": (52, Total(Units.KWH), div100), } @classmethod diff --git a/solax/inverters/x3.py b/solax/inverters/x3.py index ecea818..192f8a5 100644 --- a/solax/inverters/x3.py +++ b/solax/inverters/x3.py @@ -1,5 +1,6 @@ import voluptuous as vol from solax.inverter import InverterPost +from solax.units import Units, Total from solax.utils import startswith @@ -24,38 +25,38 @@ class X3(InverterPost): @classmethod def response_decoder(cls): return { - "PV1 Current": (0, "A"), - "PV2 Current": (1, "A"), - "PV1 Voltage": (2, "V"), - "PV2 Voltage": (3, "V"), - "Output Current Phase 1": (4, "A"), - "Network Voltage Phase 1": (5, "V"), - "AC Power": (6, "W"), - "Inverter Temperature": (7, "C"), - "Today's Energy": (8, "kWh"), - "Total Energy": (9, "kWh"), - "Exported Power": (10, "W"), - "PV1 Power": (11, "W"), - "PV2 Power": (12, "W"), - "Battery Voltage": (13, "V"), - "Battery Current": (14, "A"), - "Battery Power": (15, "W"), - "Battery Temperature": (16, "C"), - "Battery Remaining Capacity": (21, "%"), - "Total Feed-in Energy": (41, "kWh"), - "Total Consumption": (42, "kWh"), - "Power Now Phase 1": (43, "W"), - "Power Now Phase 2": (44, "W"), - "Power Now Phase 3": (45, "W"), - "Output Current Phase 2": (46, "A"), - "Output Current Phase 3": (47, "A"), - "Network Voltage Phase 2": (48, "V"), - "Network Voltage Phase 3": (49, "V"), - "Grid Frequency Phase 1": (50, "Hz"), - "Grid Frequency Phase 2": (51, "Hz"), - "Grid Frequency Phase 3": (52, "Hz"), - "EPS Voltage": (53, "V"), - "EPS Current": (54, "A"), - "EPS Power": (55, "W"), - "EPS Frequency": (56, "Hz"), + "PV1 Current": (0, Units.A), + "PV2 Current": (1, Units.A), + "PV1 Voltage": (2, Units.V), + "PV2 Voltage": (3, Units.V), + "Output Current Phase 1": (4, Units.A), + "Network Voltage Phase 1": (5, Units.V), + "AC Power": (6, Units.W), + "Inverter Temperature": (7, Units.C), + "Today's Energy": (8, Units.KWH), + "Total Energy": (9, Total(Units.KWH)), + "Exported Power": (10, Units.W), + "PV1 Power": (11, Units.W), + "PV2 Power": (12, Units.W), + "Battery Voltage": (13, Units.V), + "Battery Current": (14, Units.A), + "Battery Power": (15, Units.W), + "Battery Temperature": (16, Units.C), + "Battery Remaining Capacity": (21, Units.PERCENT), + "Total Feed-in Energy": (41, Total(Units.KWH)), + "Total Consumption": (42, Total(Units.KWH)), + "Power Now Phase 1": (43, Units.W), + "Power Now Phase 2": (44, Units.W), + "Power Now Phase 3": (45, Units.W), + "Output Current Phase 2": (46, Units.A), + "Output Current Phase 3": (47, Units.A), + "Network Voltage Phase 2": (48, Units.V), + "Network Voltage Phase 3": (49, Units.V), + "Grid Frequency Phase 1": (50, Units.HZ), + "Grid Frequency Phase 2": (51, Units.HZ), + "Grid Frequency Phase 3": (52, Units.HZ), + "EPS Voltage": (53, Units.V), + "EPS Current": (54, Units.A), + "EPS Power": (55, Units.W), + "EPS Frequency": (56, Units.HZ), } diff --git a/solax/inverters/x3_v34.py b/solax/inverters/x3_v34.py index 4c58bb2..7d75bcc 100644 --- a/solax/inverters/x3_v34.py +++ b/solax/inverters/x3_v34.py @@ -1,5 +1,6 @@ import voluptuous as vol from solax.inverter import InverterPost +from solax.units import Total, Units from solax.utils import ( div10, div100, @@ -41,51 +42,51 @@ class X3V34(InverterPost): @classmethod def response_decoder(cls): return { - "Network Voltage Phase 1": (0, "V", div10), - "Network Voltage Phase 2": (1, "V", div10), - "Network Voltage Phase 3": (2, "V", div10), - "Output Current Phase 1": (3, "A", twoway_div10), - "Output Current Phase 2": (4, "A", twoway_div10), - "Output Current Phase 3": (5, "A", twoway_div10), - "Power Now Phase 1": (6, "W", to_signed), - "Power Now Phase 2": (7, "W", to_signed), - "Power Now Phase 3": (8, "W", to_signed), - "PV1 Voltage": (9, "V", div10), - "PV2 Voltage": (10, "V", div10), - "PV1 Current": (11, "A", div10), - "PV2 Current": (12, "A", div10), - "PV1 Power": (13, "W"), - "PV2 Power": (14, "W"), - "Total PV Energy": (89, "kWh", pv_energy), - "Total PV Energy Resets": (90, ""), - "Today's PV Energy": (112, "kWh", div10), - "Grid Frequency Phase 1": (15, "Hz", div100), - "Grid Frequency Phase 2": (16, "Hz", div100), - "Grid Frequency Phase 3": (17, "Hz", div100), - "Total Energy": (19, "kWh", total_energy), - "Total Energy Resets": (20, ""), - "Today's Energy": (21, "kWh", div10), - "Battery Voltage": (24, "V", div100), - "Battery Current": (25, "A", twoway_div100), - "Battery Power": (26, "W", to_signed), - "Battery Temperature": (27, "C"), - "Battery Remaining Capacity": (28, "%"), - "Total Battery Discharge Energy": (30, "kWh", discharge_energy), - "Total Battery Discharge Energy Resets": (31, ""), - "Today's Battery Discharge Energy": (113, "kWh", div10), - "Battery Remaining Energy": (32, "kWh", div10), - "Total Battery Charge Energy": (87, "kWh", charge_energy), - "Total Battery Charge Energy Resets": (88, ""), - "Today's Battery Charge Energy": (114, "kWh", div10), - "Exported Power": (65, "W", to_signed), - "Total Feed-in Energy": (67, "kWh", feedin_energy), - "Total Feed-in Energy Resets": (68, ""), - "Total Consumption": (69, "kWh", consumption), - "Total Consumption Resets": (70, ""), - "AC Power": (181, "W", to_signed), - "EPS Frequency": (63, "Hz", div100), - "EPS Total Energy": (110, "kWh", eps_total_energy), - "EPS Total Energy Resets": (111, "Hz"), + "Network Voltage Phase 1": (0, Units.V, div10), + "Network Voltage Phase 2": (1, Units.V, div10), + "Network Voltage Phase 3": (2, Units.V, div10), + "Output Current Phase 1": (3, Units.A, twoway_div10), + "Output Current Phase 2": (4, Units.A, twoway_div10), + "Output Current Phase 3": (5, Units.A, twoway_div10), + "Power Now Phase 1": (6, Units.W, to_signed), + "Power Now Phase 2": (7, Units.W, to_signed), + "Power Now Phase 3": (8, Units.W, to_signed), + "PV1 Voltage": (9, Units.V, div10), + "PV2 Voltage": (10, Units.V, div10), + "PV1 Current": (11, Units.A, div10), + "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), + "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), + "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), + "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), + "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), + "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), } # pylint: enable=duplicate-code diff --git a/solax/inverters/x_hybrid.py b/solax/inverters/x_hybrid.py index 90ea6b9..0ab65f6 100644 --- a/solax/inverters/x_hybrid.py +++ b/solax/inverters/x_hybrid.py @@ -2,6 +2,7 @@ import aiohttp import voluptuous as vol from solax.inverter import Inverter, InverterResponse +from solax.units import Total, Units class XHybrid(Inverter): @@ -27,33 +28,37 @@ class XHybrid(Inverter): extra=vol.REMOVE_EXTRA, ) + # key: name of sensor + # value.0: index + # value.1: unit (String) or None + # from https://github.com/GitHobi/solax/wiki/direct-data-retrieval @classmethod def response_decoder(cls): return { - "PV1 Current": (0, "A"), - "PV2 Current": (1, "A"), - "PV1 Voltage": (2, "V"), - "PV2 Voltage": (3, "V"), - "Output Current": (4, "A"), - "Network Voltage": (5, "V"), - "Power Now": (6, "W"), - "Inverter Temperature": (7, "C"), - "Today's Energy": (8, "kWh"), - "Total Energy": (9, "kWh"), - "Exported Power": (10, "W"), - "PV1 Power": (11, "W"), - "PV2 Power": (12, "W"), - "Battery Voltage": (13, "V"), - "Battery Current": (14, "A"), - "Battery Power": (15, "W"), - "Battery Temperature": (16, "C"), - "Battery Remaining Capacity": (17, "%"), - "Month's Energy": (19, "kWh"), - "Grid Frequency": (50, "Hz"), - "EPS Voltage": (53, "V"), - "EPS Current": (54, "A"), - "EPS Power": (55, "W"), - "EPS Frequency": (56, "Hz"), + "PV1 Current": (0, Units.A), + "PV2 Current": (1, Units.A), + "PV1 Voltage": (2, Units.V), + "PV2 Voltage": (3, Units.V), + "Output Current": (4, Units.A), + "Network Voltage": (5, Units.V), + "Power Now": (6, Units.W), + "Inverter Temperature": (7, Units.C), + "Today's Energy": (8, Units.KWH), + "Total Energy": (9, Total(Units.KWH)), + "Exported Power": (10, Units.W), + "PV1 Power": (11, Units.W), + "PV2 Power": (12, Units.W), + "Battery Voltage": (13, Units.V), + "Battery Current": (14, Units.A), + "Battery Power": (15, Units.W), + "Battery Temperature": (16, Units.C), + "Battery Remaining Capacity": (17, Units.PERCENT), + "Month's Energy": (19, Units.KWH), + "Grid Frequency": (50, Units.HZ), + "EPS Voltage": (53, Units.V), + "EPS Current": (54, Units.A), + "EPS Power": (55, Units.W), + "EPS Frequency": (56, Units.HZ), } @classmethod diff --git a/solax/units.py b/solax/units.py new file mode 100644 index 0000000..feb19f5 --- /dev/null +++ b/solax/units.py @@ -0,0 +1,32 @@ +""" Units and different measrement types""" +from enum import Enum +from typing import NamedTuple, Union + + +class Units(Enum): + """All known Units.""" + + W = "W" + KWH = "kWh" + A = "A" + V = "V" + C = "°C" + HZ = "Hz" + PERCENT = "%" + NONE = "" + + +class Measurement(NamedTuple): + """Respresention of measurement with a given unit and arbitrary values.""" + + unit: Units + is_monotonic: bool = False + + +class Total(Measurement): + """A Measuremeant where the values are continuously increasing.""" + + is_monotonic: bool = True + + +SensorUnit = Union[Measurement, Total] diff --git a/tests/conftest.py b/tests/conftest.py index e5d9f2e..2ce5f67 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -2,3 +2,4 @@ from tests.fixtures import simple_http_fixture # noqa: F401 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 diff --git a/tests/fixtures.py b/tests/fixtures.py index 0743287..c62b1a0 100644 --- a/tests/fixtures.py +++ b/tests/fixtures.py @@ -188,6 +188,11 @@ def simple_http_fixture(httpserver): ] +@pytest.fixture(params=INVERTERS_UNDER_TEST) +def inverters_under_test(request): + yield request.param.inverter + + @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 1289a7e..1012ebf 100644 --- a/tests/test_smoke.py +++ b/tests/test_smoke.py @@ -2,7 +2,7 @@ import solax from solax.inverter import InverterError from solax.discovery import REGISTRY - +from solax.units import Measurement from tests import fixtures @@ -44,3 +44,13 @@ def test_inverter_sensors_match(): assert len(sensor_map) == len(expected_values), msg for name, _ in sensor_map.items(): assert name in expected_values + + +def test_inverter_sensors_define_valid_units(inverters_under_test): + sensor_map = inverters_under_test.sensor_map() + for name, (_, unit, *_) in sensor_map.items(): + msg = ( + f"provided unit '{unit}'({type(unit)}) " + f"is not a proper Unit on sensor '{name}' of Inverter '{inverters_under_test}'" + ) + assert isinstance(unit, Measurement), msg