Skip to content

Commit

Permalink
Add X1 hybrid gen4 (#77)
Browse files Browse the repository at this point in the history
* Per issue #76, combine multiple register values

* #76: replace resets with proposed combiner and fix tests for off-by-one error

* fix off-by-one in to_signed and add test for it

* Add missing requirement async-timeout and silence warning in it's use

* Add timeout for connections since on most recent pocket wifi post hangs

* add support for X1 Hybrid G4

* ensure inverters raise if they get an error response from endpoint

* add testing for X1 Hybrid G4

* Satisfy cov for new inverter code

* remove (unused) bare index from ResponseDecoderType and assoicated code

* transpose ResponseDecoderType

* Update type annotations for multi-register indices

* pin async_timeout version

* protocol to define bit packer

* appease linter

* remove timeout() from this PR

* fix ha interface via sensor_map

Co-authored-by: Robin Wohlers-Reichel <[email protected]>
  • Loading branch information
rupertnash and squishykid authored Sep 11, 2022
1 parent 4c4bc83 commit 7feccc8
Show file tree
Hide file tree
Showing 12 changed files with 531 additions and 172 deletions.
6 changes: 5 additions & 1 deletion setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
],
Expand Down
14 changes: 13 additions & 1 deletion solax/discovery.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
86 changes: 63 additions & 23 deletions solax/inverter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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


Expand All @@ -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)
Expand Down Expand Up @@ -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)
2 changes: 2 additions & 0 deletions solax/inverters/__init__.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -18,4 +19,5 @@
"X3V34",
"X3",
"X1Boost",
"X1HybridGen4",
]
37 changes: 17 additions & 20 deletions solax/inverters/qvolt_hyb_g3_3p.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)


Expand All @@ -29,7 +24,7 @@ class Processors:
"""

@staticmethod
def inverter_modes(value, *_args, **_kwargs):
def inverter_modes(value):
return {
0: "Waiting",
1: "Checking",
Expand All @@ -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",
Expand Down Expand Up @@ -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),
Expand Down
50 changes: 50 additions & 0 deletions solax/inverters/x1_hybrid_gen4.py
Original file line number Diff line number Diff line change
@@ -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),
}
37 changes: 16 additions & 21 deletions solax/inverters/x3_v34.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)


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

1 comment on commit 7feccc8

@ukshark
Copy link

@ukshark ukshark commented on 7feccc8 Oct 8, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you help with adding X1 hybrid Solax to Home assistant at all? thank you

Please sign in to comment.