Skip to content

Commit

Permalink
use deterministic units (#70)
Browse files Browse the repository at this point in the history
* use deterministic units

* rename default unit to NONE

* fixing all the tests I broke

* parameterise validation that sensors use custom type

Co-authored-by: Robin Wohlers-Reichel <[email protected]>
  • Loading branch information
VadimKraus and squishykid authored Jul 31, 2022
1 parent 3ec9105 commit 4c4bc83
Show file tree
Hide file tree
Showing 15 changed files with 337 additions and 258 deletions.
7 changes: 4 additions & 3 deletions solax/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,15 @@
import async_timeout

from solax.discovery import discover
from solax.inverter import Inverter, InverterResponse

_LOGGER = logging.getLogger(__name__)


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."
Expand Down Expand Up @@ -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)
45 changes: 31 additions & 14 deletions solax/inverter.py
Original file line number Diff line number Diff line change
@@ -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"""
Expand All @@ -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
Expand All @@ -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:
Expand All @@ -54,32 +57,46 @@ 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
"""
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
Expand All @@ -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]
Expand Down Expand Up @@ -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.
Expand Down
93 changes: 47 additions & 46 deletions solax/inverters/qvolt_hyb_g3_3p.py
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -77,76 +78,76 @@ 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
# 52: always 100
# 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
Expand All @@ -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
}
Expand Down
53 changes: 27 additions & 26 deletions solax/inverters/x1.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import voluptuous as vol
from solax.inverter import InverterPost
from solax.units import Units, Total
from solax.utils import startswith


Expand Down Expand Up @@ -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
33 changes: 17 additions & 16 deletions solax/inverters/x1_boost.py
Original file line number Diff line number Diff line change
@@ -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


Expand Down Expand Up @@ -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
Expand Down
Loading

0 comments on commit 4c4bc83

Please sign in to comment.