diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index dacad3e..c5b7a35 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -19,7 +19,7 @@ jobs: steps: - uses: actions/checkout@v1 - name: Set up Python - uses: actions/setup-python@v1 + uses: actions/setup-python@v5 with: python-version: '3.x' - name: Install dependencies diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index 89aada1..d6583f5 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -8,7 +8,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: [3.8, 3.9, '3.10'] + python-version: [3.8, 3.9, '3.10', '3.11', '3.12'] steps: - uses: actions/checkout@v4 @@ -19,7 +19,7 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip - python setup.py install + python -m pip install . pip install --upgrade flake8 pylint pytest pytest-cov pytest-asyncio pytest-httpserver black mypy isort - name: Check code style with black run: | @@ -38,4 +38,31 @@ jobs: pylint -d 'C0111' solax tests - name: Test with pytest run: | - pytest --cov=solax --cov-fail-under=100 --cov-branch --cov-report=term-missing . + pytest --cov=solax --cov-branch --cov-report=term-missing . + mv .coverage .coverage.${{ matrix.python-version }} + - name: Upload coverage + uses: actions/upload-artifact@v4 + with: + name: .coverage-${{ matrix.python-version }} + path: .coverage.${{ matrix.python-version }} + if-no-files-found: error + + coverage: + runs-on: ubuntu-latest + needs: [build] + steps: + - uses: actions/checkout@v4 + - name: Set up Python 3.12 + uses: actions/setup-python@v5 + with: + python-version: 3.12 + - name: Download coverage files + uses: actions/download-artifact@v4 + with: + merge-multiple: true + - name: Coverage combine + run: | + python -m pip install --upgrade pip + pip install --upgrade coverage + coverage combine + coverage report -m --fail-under=100 diff --git a/setup.py b/setup.py index 759b61e..515608c 100644 --- a/setup.py +++ b/setup.py @@ -18,6 +18,8 @@ "aiohttp>=3.5.4, <4", "async_timeout>=4.0.2", "voluptuous>=0.11.5", + "importlib_metadata>=3.6; python_version<'3.10'", + "typing_extensions>=4.1.0; python_version<'3.11'", ], setup_requires=[ "setuptools_scm", @@ -28,4 +30,20 @@ "Operating System :: OS Independent", ], python_requires=">=3.8", + entry_points={ + "solax.inverter": [ + "qvolt_hyb_g3_3p = solax.inverters.qvolt_hyb_g3_3p:QVOLTHYBG33P", + "x1 = solax.inverters.x1:X1", + "x1_boost = solax.inverters.x1_boost:X1Boost", + "x1_hybrid_gen4 = solax.inverters.x1_hybrid_gen4:X1HybridGen4", + "x1_mini = solax.inverters.x1_mini:X1Mini", + "x1_mini_v34 = solax.inverters.x1_mini_v34:X1MiniV34", + "x1_smart = solax.inverters.x1_smart:X1Smart", + "x3 = solax.inverters.x3:X3", + "x3_hybrid_g4 = solax.inverters.x3_hybrid_g4:X3HybridG4", + "x3_mic_pro_g2 = solax.inverters.x3_mic_pro_g2:X3MicProG2", + "x3_v34 = solax.inverters.x3_v34:X3V34", + "x_hybrid = solax.inverters.x_hybrid:XHybrid", + ], + }, ) diff --git a/solax/__init__.py b/solax/__init__.py index 6fd9817..1ac860c 100644 --- a/solax/__init__.py +++ b/solax/__init__.py @@ -33,7 +33,7 @@ async def rt_request(inv: Inverter, retry, t_wait=0) -> InverterResponse: async def real_time_api(ip_address, port=80, pwd=""): - i = await discover(ip_address, port, pwd) + i = await discover(ip_address, port, pwd, return_when=asyncio.FIRST_COMPLETED) return RealTimeAPI(i) diff --git a/solax/discovery.py b/solax/discovery.py index 9cfdd60..77d144f 100644 --- a/solax/discovery.py +++ b/solax/discovery.py @@ -1,103 +1,161 @@ import asyncio import logging -import typing - -from solax.inverter import Inverter, InverterError -from solax.inverters import ( - QVOLTHYBG33P, - X1, - X3, - X3V34, - X1Boost, - X1HybridGen4, - X1Mini, - X1MiniV34, - X1Smart, - X3HybridG4, - X3MicProG2, - XHybrid, -) +import sys +from asyncio import Future, Task +from collections import defaultdict +from typing import Dict, Literal, Optional, Sequence, Set, TypedDict, Union, cast -# registry of inverters -REGISTRY = [ - XHybrid, - X3, - X3V34, - X3HybridG4, - X1, - X1Mini, - X1MiniV34, - X1Smart, - QVOLTHYBG33P, - X1Boost, - X1HybridGen4, - X3MicProG2, -] +from async_timeout import timeout + +from solax.inverter import Inverter +from solax.inverter_http_client import InverterHttpClient + +__all__ = ("discover", "DiscoveryKeywords", "DiscoveryError") +if sys.version_info >= (3, 10): + from importlib.metadata import entry_points +else: + from importlib_metadata import entry_points + +if sys.version_info >= (3, 11): + from typing import Unpack +else: + from typing_extensions import Unpack + +# registry of inverters +REGISTRY = {ep.load() for ep in entry_points(group="solax.inverter")} logging.basicConfig(level=logging.INFO) -class DiscoveryState: - _discovered_inverter: typing.Optional[Inverter] - _tasks: typing.Set[asyncio.Task] - _failures: list - - def __init__(self): - self._discovered_inverter = None - self._tasks = set() - self._failures = [] - - def get_discovered_inverter(self): - return self._discovered_inverter - - def _task_handler(self, task): - try: - self._tasks.remove(task) - result = task.result() - self._discovered_inverter = result - for a_task in self._tasks: - a_task.cancel() - except asyncio.CancelledError: - logging.debug("task %s canceled", task.get_name()) - except InverterError as ex: - self._failures.append(ex) - - @classmethod - async def _discovery_task(cls, i) -> Inverter: - logging.info("Trying inverter %s", i) - await i.get_data() - return i - - async def discover(self, host, port, pwd="") -> Inverter: - for inverter in REGISTRY: - for i in inverter.build_all_variants(host, port, pwd): - task = asyncio.create_task(self._discovery_task(i), name=f"{i}") - task.add_done_callback(self._task_handler) - self._tasks.add(task) - - while len(self._tasks) > 0: - logging.debug("%d discovery tasks are still running...", len(self._tasks)) - await asyncio.sleep(0.5) - - if self._discovered_inverter is not None: - logging.info("Discovered inverter: %s", self._discovered_inverter) - return self._discovered_inverter - - msg = ( +class DiscoveryKeywords(TypedDict, total=False): + timeout: Optional[float] + inverters: Sequence[Inverter] + return_when: Union[Literal["ALL_COMPLETED"], Literal["FIRST_COMPLETED"]] + + +if sys.version_info >= (3, 9): + _InverterTask = Task[Inverter] +else: + _InverterTask = Task + + +class _DiscoveryHttpClient: + def __init__( + self, + inverter: Inverter, + http_client: InverterHttpClient, + request: Future, + ): + self._inverter = inverter + self._http_client = http_client + self._request: Future = request + + def __str__(self): + return str(self._http_client) + + async def request(self): + request = await self._request + request.add_done_callback(self._restore_http_client) + return await request + + def _restore_http_client(self, _: _InverterTask): + self._inverter.http_client = self._http_client + + +async def _discovery_task(i) -> Inverter: + logging.info("Trying inverter %s", i) + await i.get_data() + return i + + +async def discover( + host, port, pwd="", **kwargs: Unpack[DiscoveryKeywords] +) -> Union[Inverter, Set[Inverter]]: + async with timeout(kwargs.get("timeout", 15)): + done: Set[_InverterTask] = set() + pending: Set[_InverterTask] = set() + failures = set() + requests: Dict[InverterHttpClient, Future] = defaultdict( + asyncio.get_running_loop().create_future + ) + + return_when = kwargs.get("return_when", asyncio.FIRST_COMPLETED) + for cls in kwargs.get("inverters", REGISTRY): + for inverter in cls.build_all_variants(host, port, pwd): + inverter.http_client = cast( + InverterHttpClient, + _DiscoveryHttpClient( + inverter, inverter.http_client, requests[inverter.http_client] + ), + ) + + pending.add( + asyncio.create_task(_discovery_task(inverter), name=f"{inverter}") + ) + + if not pending: + raise DiscoveryError("No inverters to try to discover") + + def cancel(pending: Set[_InverterTask]) -> Set[_InverterTask]: + for task in pending: + task.cancel() + return pending + + def remove_failures_from(done: Set[_InverterTask]) -> None: + for task in set(done): + exc = task.exception() + if exc: + failures.add(exc) + done.remove(task) + + # stagger HTTP request to prevent accidental Denial Of Service + async def stagger() -> None: + for http_client, future in requests.items(): + future.set_result(asyncio.create_task(http_client.request())) + await asyncio.sleep(1) + + staggered = asyncio.create_task(stagger()) + + while pending and (not done or return_when != asyncio.FIRST_COMPLETED): + try: + done, pending = await asyncio.wait(pending, return_when=return_when) + except asyncio.CancelledError: + staggered.cancel() + await asyncio.gather( + staggered, *cancel(pending), return_exceptions=True + ) + raise + + remove_failures_from(done) + + if done and return_when == asyncio.FIRST_COMPLETED: + break + + logging.debug("%d discovery tasks are still running...", len(pending)) + + if pending and return_when != asyncio.FIRST_COMPLETED: + pending.update(done) + done.clear() + + remove_failures_from(done) + staggered.cancel() + await asyncio.gather(staggered, *cancel(pending), return_exceptions=True) + + if done: + logging.info("Discovered inverters: %s", {task.result() for task in done}) + if return_when == asyncio.FIRST_COMPLETED: + return await next(iter(done)) + + return {task.result() for task in done} + + raise DiscoveryError( "Unable to connect to the inverter at " f"host={host} port={port}, or your inverter is not supported yet.\n" "Please see https://github.com/squishykid/solax/wiki/DiscoveryError\n" - f"Failures={str(self._failures)}" + f"Failures={str(failures)}" ) - raise DiscoveryError(msg) class DiscoveryError(Exception): """Raised when unable to discover inverter""" - - -async def discover(host, port, pwd="") -> Inverter: - discover_state = DiscoveryState() - await discover_state.discover(host, port, pwd) - return discover_state.get_discovered_inverter() diff --git a/solax/inverter.py b/solax/inverter.py index 5304263..8abe642 100644 --- a/solax/inverter.py +++ b/solax/inverter.py @@ -1,4 +1,5 @@ -from typing import Dict, Tuple +from abc import abstractmethod +from typing import Any, Dict, Optional, Tuple import aiohttp import voluptuous as vol @@ -27,33 +28,38 @@ def response_decoder(cls) -> ResponseDecoder: # pylint: enable=C0301 _schema = vol.Schema({}) # type: vol.Schema - def __init__( - self, http_client: InverterHttpClient, response_parser: ResponseParser - ): + def __init__(self, http_client: InverterHttpClient): self.manufacturer = "Solax" - self.response_parser = response_parser self.http_client = http_client + schema = type(self).schema() + response_decoder = type(self).response_decoder() + dongle_serial_number_getter = type(self).dongle_serial_number_getter + inverter_serial_number_getter = type(self).inverter_serial_number_getter + self.response_parser = ResponseParser( + schema, + response_decoder, + dongle_serial_number_getter, + inverter_serial_number_getter, + ) + @classmethod def _build(cls, host, port, pwd="", params_in_query=True): url = utils.to_url(host, port) - http_client = InverterHttpClient(url, Method.POST, pwd) + http_client = InverterHttpClient(url=url, method=Method.POST, pwd=pwd) if params_in_query: - http_client.with_default_query() + http_client = http_client.with_default_query() else: - http_client.with_default_data() + http_client = http_client.with_default_data() - schema = cls.schema() - response_decoder = cls.response_decoder() - response_parser = ResponseParser(schema, response_decoder) - return cls(http_client, response_parser) + return cls(http_client) @classmethod def build_all_variants(cls, host, port, pwd=""): - versions = [ + versions = { cls._build(host, port, pwd, True), cls._build(host, port, pwd, False), - ] + } return versions async def get_data(self) -> InverterResponse: @@ -105,5 +111,14 @@ def schema(cls) -> vol.Schema: """ return cls._schema + @classmethod + def dongle_serial_number_getter(cls, response: Dict[str, Any]) -> Optional[str]: + return response["sn"] + + @classmethod + @abstractmethod + def inverter_serial_number_getter(cls, response: Dict[str, Any]) -> Optional[str]: + raise NotImplementedError # pragma: no cover + def __str__(self) -> str: return f"{self.__class__.__name__}::{self.http_client}" diff --git a/solax/inverter_http_client.py b/solax/inverter_http_client.py index cfea7f6..e68adce 100644 --- a/solax/inverter_http_client.py +++ b/solax/inverter_http_client.py @@ -1,47 +1,96 @@ +from __future__ import annotations + +import dataclasses +import sys +from dataclasses import dataclass, field from enum import Enum +from typing import Dict, Optional +from weakref import WeakValueDictionary import aiohttp +__all__ = ("InverterHttpClient", "Method") + +if sys.version_info >= (3, 10): + from dataclasses import KW_ONLY + +_CACHE: WeakValueDictionary[int, InverterHttpClient] = WeakValueDictionary() + class Method(Enum): GET = 1 POST = 2 +_kwargs: Dict[str, bool] = {} + +if sys.version_info >= (3, 11): + _kwargs["slots"] = True + _kwargs["weakref_slot"] = True + + +@dataclass(frozen=True, **_kwargs) class InverterHttpClient: - def __init__(self, url, method: Method = Method.POST, pwd=""): - """Initialize the Http client.""" - self.url = url - self.method = method - self.pwd = pwd - self.headers = None - self.data = None - self.query = "" - - @classmethod - def build_w_url(cls, url, method: Method = Method.POST): - http_client = cls(url, method, "") - return http_client - - def with_headers(self, headers): - self.headers = headers - return self - - def with_default_data(self): + """Initialize the Http client.""" + + if sys.version_info >= (3, 10): + _: KW_ONLY + + url: str + method: Method + pwd: str + headers: Dict[str, str] = field(default_factory=dict) + data: Optional[bytes] = None + query: str = "" + + def __hash__(self): + return id(self) + + def replace(self, **kwargs) -> InverterHttpClient: + fields = dataclasses.fields(InverterHttpClient) + data = {} + values = [] + + for fld in fields: + if fld.name in kwargs: + value = kwargs.pop(fld.name) + else: + value = getattr(self, fld.name) + + data[fld.name] = value + + if isinstance(value, dict): + value = dict(value) + values.append(tuple(value.items())) + else: + values.append(value) + + data[fld.name] = value + + key = hash(tuple(values)) + cached = _CACHE.get(key) + + if cached is None: + cached = _CACHE[key] = InverterHttpClient(**data) + + return cached + + def with_headers(self, headers) -> InverterHttpClient: + return self.replace(headers=dict(headers)) + + def with_default_data(self) -> InverterHttpClient: data = "optType=ReadRealTimeData" if self.pwd: data = data + "&pwd=" + self.pwd return self.with_data(data) - def with_data(self, data): - self.data = data - return self + def with_data(self, data) -> InverterHttpClient: + return self.replace(data=data) - def with_query(self, query): - self.query = query - return self + def with_query(self, query) -> InverterHttpClient: + return self.replace(query=query) - def with_default_query(self): + def with_default_query(self) -> InverterHttpClient: if self.pwd: base = "optType=ReadRealTimeData&pwd={}&" query = base.format(self.pwd) diff --git a/solax/inverters/qvolt_hyb_g3_3p.py b/solax/inverters/qvolt_hyb_g3_3p.py index f686d3e..5bda4ee 100644 --- a/solax/inverters/qvolt_hyb_g3_3p.py +++ b/solax/inverters/qvolt_hyb_g3_3p.py @@ -1,8 +1,9 @@ +from typing import Any, Dict, Optional + import voluptuous as vol -from solax import utils -from solax.inverter import Inverter, InverterHttpClient, Method, ResponseParser -from solax.units import Total, Units +from solax.inverter import Inverter, InverterHttpClient +from solax.units import DailyTotal, Measurement, Total, Units from solax.utils import div10, div100, pack_u16, to_signed, twoway_div10, twoway_div100 @@ -42,10 +43,8 @@ def battery_modes(value): 3: "Feed-in Priority", }.get(value, f"unmapped value '{value}'") - def __init__( - self, http_client: InverterHttpClient, response_parser: ResponseParser - ): - super().__init__(http_client, response_parser) + def __init__(self, http_client: InverterHttpClient, *args, **kwargs): + super().__init__(http_client, *args, **kwargs) self.manufacturer = "Qcells" _schema = vol.Schema( @@ -53,13 +52,13 @@ def __init__( vol.Required("type"): vol.All(int, 14), vol.Required("sn"): str, vol.Required("ver"): str, - vol.Required("Data"): vol.Schema( + vol.Required("data"): vol.Schema( vol.All( [vol.Coerce(float)], vol.Length(min=200, max=200), ) ), - vol.Required("Information"): vol.Schema( + vol.Required("information"): vol.Schema( vol.All(vol.Length(min=10, max=10)) ), }, @@ -125,22 +124,26 @@ def response_decoder(cls): Total(Units.KWH), div10, ), - "Today's Battery Discharge Energy": (78, Units.KWH, div10), - "Today's Battery Charge Energy": (79, Units.KWH, div10), + "Today's Battery Discharge Energy": (78, DailyTotal(Units.KWH), div10), + "Today's Battery Charge Energy": (79, DailyTotal(Units.KWH), div10), "Total PV Energy": (pack_u16(80, 81), Total(Units.KWH), div10), - "Today's Energy": (82, Units.KWH, div10), + "Today's Energy": (82, DailyTotal(Units.KWH), div10), # 83-85: always 0 "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), + "Today's Feed-in Energy": (90, DailyTotal(Units.KWH), div100), # 91: always 0 - "Today's Consumption": (92, Units.KWH, div100), + "Today's Consumption": (92, DailyTotal(Units.KWH), div100), # 93-101: always 0 # 102: always 1 "Battery Remaining Capacity": (103, Units.PERCENT), # 104: always 1 "Battery Temperature": (105, Units.C), - "Battery Remaining Energy": (106, Units.KWH, div10), + "Battery Remaining Energy": ( + 106, + Measurement(Units.KWH, storage=True), + div10, + ), # 107: always 256 or 0 # 108: always 3504 # 109: always 2400 @@ -161,16 +164,10 @@ def response_decoder(cls): } @classmethod - def _build(cls, host, port, pwd="", params_in_query=True): - url = utils.to_url(host, port) - http_client = InverterHttpClient(url, Method.POST, pwd).with_default_data() - - schema = cls._schema - response_decoder = cls.response_decoder() - response_parser = ResponseParser(schema, response_decoder) - return cls(http_client, response_parser) + 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)] + versions = [cls._build(host, port, pwd, False)] return versions diff --git a/solax/inverters/x1.py b/solax/inverters/x1.py index 5abf23d..54121ba 100644 --- a/solax/inverters/x1.py +++ b/solax/inverters/x1.py @@ -1,7 +1,9 @@ +from typing import Any, Dict, Optional + import voluptuous as vol from solax.inverter import Inverter -from solax.units import Total, Units +from solax.units import DailyTotal, Total, Units from solax.utils import startswith @@ -10,9 +12,9 @@ class X1(Inverter): _schema = vol.Schema( { vol.Required("type"): vol.All(str, startswith("X1-")), - vol.Required("SN"): str, + vol.Required("sn"): str, vol.Required("ver"): str, - vol.Required("Data"): vol.Schema( + vol.Required("data"): vol.Schema( vol.All( [vol.Coerce(float)], vol.Any( @@ -22,7 +24,7 @@ class X1(Inverter): ), ) ), - vol.Required("Information"): vol.Schema(vol.All(vol.Length(min=9, max=9))), + vol.Required("information"): vol.Schema(vol.All(vol.Length(min=9, max=9))), }, extra=vol.REMOVE_EXTRA, ) @@ -38,7 +40,7 @@ def response_decoder(cls): "Network Voltage": (5, Units.V), "AC Power": (6, Units.W), "Inverter Temperature": (7, Units.C), - "Today's Energy": (8, Units.KWH), + "Today's Energy": (8, DailyTotal(Units.KWH)), "Total Energy": (9, Total(Units.KWH)), "Exported Power": (10, Units.W), "PV1 Power": (11, Units.W), @@ -59,3 +61,7 @@ def response_decoder(cls): } # pylint: enable=duplicate-code + + @classmethod + def inverter_serial_number_getter(cls, response: Dict[str, Any]) -> Optional[str]: + return response["information"][3] diff --git a/solax/inverters/x1_boost.py b/solax/inverters/x1_boost.py index 58e92ab..9ff4b11 100644 --- a/solax/inverters/x1_boost.py +++ b/solax/inverters/x1_boost.py @@ -1,8 +1,9 @@ +from typing import Any, Dict, Optional + import voluptuous as vol -from solax import utils -from solax.inverter import Inverter, InverterHttpClient, Method, ResponseParser -from solax.units import Total, Units +from solax.inverter import Inverter +from solax.units import DailyTotal, Total, Units from solax.utils import div10, div100, pack_u16, to_signed @@ -20,13 +21,13 @@ class X1Boost(Inverter): "sn", ): str, vol.Required("ver"): str, - vol.Required("Data"): vol.Schema( + vol.Required("data"): vol.Schema( vol.All( [vol.Coerce(float)], vol.Length(min=200, max=200), ) ), - vol.Required("Information"): vol.Schema( + vol.Required("information"): vol.Schema( vol.All(vol.Length(min=10, max=10)) ), }, @@ -47,7 +48,7 @@ def response_decoder(cls): "PV2 Power": (8, Units.W), "AC Frequency": (9, Units.HZ, div100), "Total Generated Energy": (pack_u16(11, 12), Total(Units.KWH), div10), - "Today's Generated Energy": (13, Total(Units.KWH), div10), + "Today's Generated Energy": (13, DailyTotal(Units.KWH), div10), "Inverter Temperature": (39, Units.C), "Exported Power": (48, Units.W, to_signed), "Total Export Energy": (pack_u16(50, 51), Total(Units.KWH), div100), @@ -55,20 +56,8 @@ def response_decoder(cls): } @classmethod - def _build(cls, host, port, pwd="", params_in_query=True): - url = utils.to_url(host, port) - http_client = InverterHttpClient(url, Method.POST, pwd) - if params_in_query: - http_client.with_default_query() - else: - http_client.with_default_data() - - headers = {"X-Forwarded-For": "5.8.8.8"} - http_client.with_headers(headers) - schema = cls._schema - response_decoder = cls.response_decoder() - response_parser = ResponseParser(schema, response_decoder) - return cls(http_client, response_parser) + 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=""): @@ -76,4 +65,8 @@ def build_all_variants(cls, host, port, pwd=""): cls._build(host, port, pwd, True), cls._build(host, port, pwd, False), ] + for inverter in versions: + inverter.http_client = inverter.http_client.with_headers( + {"X-Forwarded-For": "5.8.8.8"} + ) return versions diff --git a/solax/inverters/x1_hybrid_gen4.py b/solax/inverters/x1_hybrid_gen4.py index 0962586..6e2c852 100644 --- a/solax/inverters/x1_hybrid_gen4.py +++ b/solax/inverters/x1_hybrid_gen4.py @@ -1,8 +1,9 @@ +from typing import Any, Dict, Optional + import voluptuous as vol -from solax import utils -from solax.inverter import Inverter, InverterHttpClient, Method, ResponseParser -from solax.units import Total, Units +from solax.inverter import Inverter +from solax.units import DailyTotal, Total, Units from solax.utils import div10, div100, pack_u16, to_signed @@ -15,28 +16,20 @@ class X1HybridGen4(Inverter): "sn", ): str, vol.Required("ver"): str, - vol.Required("Data"): vol.Schema( + 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))), + vol.Required("information"): vol.Schema(vol.All(vol.Length(min=9, max=10))), }, extra=vol.REMOVE_EXTRA, ) - @classmethod - def _build(cls, host, port, pwd="", params_in_query=True): - url = utils.to_url(host, port) - http_client = InverterHttpClient(url, Method.POST, pwd).with_default_data() - - response_parser = ResponseParser(cls._schema, cls.response_decoder()) - return cls(http_client, response_parser) - @classmethod def build_all_variants(cls, host, port, pwd=""): - versions = [cls._build(host, port, pwd)] + versions = [cls._build(host, port, pwd, False)] return versions @classmethod @@ -53,7 +46,7 @@ def response_decoder(cls): "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), + "On-grid daily yield": (13, DailyTotal(Units.KWH), div10), "Battery voltage": (14, Units.V, div100), "Battery current": (15, Units.A, div100), "Battery power": (16, Units.W), @@ -63,3 +56,7 @@ def response_decoder(cls): "Total feed-in energy": (pack_u16(34, 35), Total(Units.KWH), div100), "Total consumption": (pack_u16(36, 37), Total(Units.KWH), div100), } + + @classmethod + def inverter_serial_number_getter(cls, response: Dict[str, Any]) -> Optional[str]: + return response["information"][2] diff --git a/solax/inverters/x1_mini.py b/solax/inverters/x1_mini.py index 7d0cb1a..5968e36 100644 --- a/solax/inverters/x1_mini.py +++ b/solax/inverters/x1_mini.py @@ -1,7 +1,9 @@ +from typing import Any, Dict, Optional + import voluptuous as vol from solax.inverter import Inverter -from solax.units import Total, Units +from solax.units import DailyTotal, Total, Units from solax.utils import startswith @@ -10,15 +12,15 @@ class X1Mini(Inverter): _schema = vol.Schema( { vol.Required("type"): vol.All(str, startswith("X1-")), - vol.Required("SN"): str, + vol.Required("sn"): str, vol.Required("ver"): str, - vol.Required("Data"): vol.Schema( + vol.Required("data"): vol.Schema( vol.All( [vol.Coerce(float)], vol.Length(min=69, max=69), ) ), - vol.Required("Information"): vol.Schema(vol.All(vol.Length(min=9, max=9))), + vol.Required("information"): vol.Schema(vol.All(vol.Length(min=9, max=9))), }, extra=vol.REMOVE_EXTRA, ) @@ -34,7 +36,7 @@ def response_decoder(cls): "Network Voltage": (5, Units.V), "AC Power": (6, Units.W), "Inverter Temperature": (7, Units.C), - "Today's Energy": (8, Units.KWH), + "Today's Energy": (8, DailyTotal(Units.KWH)), "Total Energy": (9, Total(Units.KWH)), "Exported Power": (10, Units.W), "PV1 Power": (11, Units.W), @@ -46,3 +48,7 @@ def response_decoder(cls): } # pylint: enable=duplicate-code + + @classmethod + def inverter_serial_number_getter(cls, response: Dict[str, Any]) -> Optional[str]: + return response["information"][3] diff --git a/solax/inverters/x1_mini_v34.py b/solax/inverters/x1_mini_v34.py index 90906ed..89cb163 100644 --- a/solax/inverters/x1_mini_v34.py +++ b/solax/inverters/x1_mini_v34.py @@ -1,7 +1,9 @@ +from typing import Any, Dict, Optional + import voluptuous as vol from solax.inverter import Inverter -from solax.units import Total, Units +from solax.units import DailyTotal, Total, Units from solax.utils import div10, div100 @@ -22,7 +24,7 @@ class X1MiniV34(Inverter): "sn", ): str, vol.Required("ver"): str, - vol.Required("Data"): vol.Schema( + vol.Required("data"): vol.Schema( vol.All( [vol.Coerce(float)], vol.Any( @@ -32,7 +34,7 @@ class X1MiniV34(Inverter): ), ) ), - vol.Required("Information"): vol.Schema( + vol.Required("information"): vol.Schema( vol.Any(vol.Length(min=9, max=9), vol.Length(min=10, max=10)) ), }, @@ -53,7 +55,7 @@ def response_decoder(cls): "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), + "Today's Energy": (13, DailyTotal(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), @@ -61,3 +63,7 @@ def response_decoder(cls): } # pylint: enable=duplicate-code + + @classmethod + def inverter_serial_number_getter(cls, response: Dict[str, Any]) -> Optional[str]: + return response["information"][2] diff --git a/solax/inverters/x1_smart.py b/solax/inverters/x1_smart.py index 6da7cc0..2a014eb 100644 --- a/solax/inverters/x1_smart.py +++ b/solax/inverters/x1_smart.py @@ -1,8 +1,9 @@ +from typing import Any, Dict, Optional + import voluptuous as vol -from solax import utils -from solax.inverter import Inverter, InverterHttpClient, Method, ResponseParser -from solax.units import Total, Units +from solax.inverter import Inverter +from solax.units import DailyTotal, Total, Units from solax.utils import div10, div100, to_signed @@ -20,13 +21,13 @@ class X1Smart(Inverter): "sn", ): str, vol.Required("ver"): str, - vol.Required("Data"): vol.Schema( + 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=8, max=8))), + vol.Required("information"): vol.Schema(vol.All(vol.Length(min=8, max=8))), }, extra=vol.REMOVE_EXTRA, ) @@ -45,7 +46,7 @@ def response_decoder(cls): "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), + "Today's Energy": (13, DailyTotal(Units.KWH), div10), "Inverter Temperature": (39, Units.C), "Exported Power": (48, Units.W, to_signed), "Total Feed-in Energy": (50, Total(Units.KWH), div100), @@ -53,20 +54,8 @@ def response_decoder(cls): } @classmethod - def _build(cls, host, port, pwd="", params_in_query=True): - url = utils.to_url(host, port) - http_client = InverterHttpClient(url, Method.POST, pwd) - if params_in_query: - http_client.with_default_query() - else: - http_client.with_default_data() - - headers = {"X-Forwarded-For": "5.8.8.8"} - http_client.with_headers(headers) - schema = cls._schema - response_decoder = cls.response_decoder() - response_parser = ResponseParser(schema, response_decoder) - return cls(http_client, response_parser) + 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=""): @@ -74,4 +63,8 @@ def build_all_variants(cls, host, port, pwd=""): cls._build(host, port, pwd, True), cls._build(host, port, pwd, False), ] + for inverter in versions: + inverter.http_client = inverter.http_client.with_headers( + {"X-Forwarded-For": "5.8.8.8"} + ) return versions diff --git a/solax/inverters/x3.py b/solax/inverters/x3.py index f69c7ad..f562b0f 100644 --- a/solax/inverters/x3.py +++ b/solax/inverters/x3.py @@ -1,28 +1,30 @@ +from typing import Any, Dict, Optional + import voluptuous as vol from solax.inverter import Inverter -from solax.units import Total, Units +from solax.units import DailyTotal, Total, Units from solax.utils import startswith class X3(Inverter): + # pylint: disable=duplicate-code _schema = vol.Schema( { vol.Required("type"): vol.All(str, startswith("X3-")), - vol.Required("SN"): str, + vol.Required("sn"): str, vol.Required("ver"): str, - vol.Required("Data"): vol.Schema( + vol.Required("data"): vol.Schema( vol.All( [vol.Coerce(float)], vol.Any(vol.Length(min=102, max=103), vol.Length(min=107, max=107)), ) ), - vol.Required("Information"): vol.Schema(vol.All(vol.Length(min=9, max=9))), + vol.Required("information"): vol.Schema(vol.All(vol.Length(min=9, max=9))), }, extra=vol.REMOVE_EXTRA, ) - # pylint: disable=duplicate-code @classmethod def response_decoder(cls): return { @@ -34,7 +36,7 @@ def response_decoder(cls): "Network Voltage Phase 1": (5, Units.V), "AC Power": (6, Units.W), "Inverter Temperature": (7, Units.C), - "Today's Energy": (8, Units.KWH), + "Today's Energy": (8, DailyTotal(Units.KWH)), "Total Energy": (9, Total(Units.KWH)), "Exported Power": (10, Units.W), "PV1 Power": (11, Units.W), @@ -61,3 +63,7 @@ def response_decoder(cls): "EPS Power": (55, Units.W), "EPS Frequency": (56, Units.HZ), } + + @classmethod + def inverter_serial_number_getter(cls, response: Dict[str, Any]) -> Optional[str]: + return response["information"][3] diff --git a/solax/inverters/x3_hybrid_g4.py b/solax/inverters/x3_hybrid_g4.py index 34048da..2665060 100644 --- a/solax/inverters/x3_hybrid_g4.py +++ b/solax/inverters/x3_hybrid_g4.py @@ -1,7 +1,9 @@ +from typing import Any, Dict, Optional + import voluptuous as vol from solax.inverter import Inverter -from solax.units import Total, Units +from solax.units import DailyTotal, Measurement, Total, Units from solax.utils import ( div10, div100, @@ -22,13 +24,13 @@ class X3HybridG4(Inverter): vol.Required("type"): vol.All(int, 14), vol.Required("sn"): str, vol.Required("ver"): str, - vol.Required("Data"): vol.Schema( + vol.Required("data"): vol.Schema( vol.All( [vol.Coerce(float)], vol.Length(min=300, max=300), ) ), - vol.Required("Information"): vol.Schema( + vol.Required("information"): vol.Schema( vol.All(vol.Length(min=10, max=10)) ), }, @@ -104,28 +106,36 @@ def response_decoder(cls): "Load/Generator Power": (47, Units.W, to_signed), "Radiator Temperature": (54, Units.C, to_signed), "Yield total": (pack_u16(68, 69), Total(Units.KWH), div10), - "Yield today": (70, Units.KWH, div10), + "Yield today": (70, DailyTotal(Units.KWH), div10), "Battery Discharge Energy total": ( pack_u16(74, 75), Total(Units.KWH), div10, ), "Battery Charge Energy total": (pack_u16(76, 77), Total(Units.KWH), div10), - "Battery Discharge Energy today": (78, Units.KWH, div10), - "Battery Charge Energy today": (79, Units.KWH, div10), + "Battery Discharge Energy today": (78, DailyTotal(Units.KWH), div10), + "Battery Charge Energy today": (79, DailyTotal(Units.KWH), div10), "PV Energy total": (pack_u16(80, 81), Total(Units.KWH), div10), "EPS Energy total": (pack_u16(83, 84), Total(Units.KWH), div10), - "EPS Energy today": (85, Units.KWH, div10), + "EPS Energy today": (85, DailyTotal(Units.KWH), div10), "Feed-in Energy": (pack_u16(86, 87), Total(Units.KWH), div100), "Consumed Energy": (pack_u16(88, 89), Total(Units.KWH), div100), "Feed-in Energy total": (pack_u16(90, 91), Total(Units.KWH), div100), "Consumed Energy total": (pack_u16(92, 93), Total(Units.KWH), div100), "Battery Remaining Capacity": (103, Units.PERCENT), "Battery Temperature": (105, Units.C, to_signed), - "Battery Remaining Energy": (106, Units.KWH, div10), + "Battery Remaining Energy": ( + 106, + Measurement(Units.KWH, storage=True), + div10, + ), "Battery mode": (168, Units.NONE), "Battery mode text": (168, Units.NONE, X3HybridG4._decode_battery_mode), "Battery Voltage": (pack_u16(169, 170), Units.V, div100), } # pylint: enable=duplicate-code + + @classmethod + def inverter_serial_number_getter(cls, response: Dict[str, Any]) -> Optional[str]: + return response["information"][2] diff --git a/solax/inverters/x3_mic_pro_g2.py b/solax/inverters/x3_mic_pro_g2.py index 76df85f..93c7a63 100644 --- a/solax/inverters/x3_mic_pro_g2.py +++ b/solax/inverters/x3_mic_pro_g2.py @@ -1,7 +1,9 @@ +from typing import Any, Dict, Optional + import voluptuous as vol from solax.inverter import Inverter -from solax.units import Total, Units +from solax.units import DailyTotal, Total, Units from solax.utils import div10, div100, pack_u16, to_signed, to_signed32, twoway_div10 @@ -14,13 +16,13 @@ class X3MicProG2(Inverter): vol.Required("type"): vol.All(int, 16), vol.Required("sn"): str, vol.Required("ver"): str, - vol.Required("Data"): vol.Schema( + vol.Required("data"): vol.Schema( vol.All( [vol.Coerce(float)], vol.Length(min=100, max=100), ) ), - vol.Required("Information"): vol.Schema( + vol.Required("information"): vol.Schema( vol.All(vol.Length(min=10, max=10)) ), }, @@ -69,10 +71,14 @@ def response_decoder(cls): # "Run Mode": (21, Units.NONE), "Run Mode": (21, Units.NONE, X3MicProG2._decode_run_mode), "Total Yield": (pack_u16(22, 23), Total(Units.KWH), div10), - "Daily Yield": (24, Units.KWH, div10), + "Daily Yield": (24, DailyTotal(Units.KWH), div10), "Feed-in Power ": (pack_u16(72, 73), Units.W, to_signed32), "Total Feed-in Energy": (pack_u16(74, 75), Total(Units.KWH), div100), "Total Consumption": (pack_u16(76, 77), Total(Units.KWH), div100), } # pylint: enable=duplicate-code + + @classmethod + def inverter_serial_number_getter(cls, response: Dict[str, Any]) -> Optional[str]: + return response["information"][2] diff --git a/solax/inverters/x3_v34.py b/solax/inverters/x3_v34.py index 029f912..af2eb42 100644 --- a/solax/inverters/x3_v34.py +++ b/solax/inverters/x3_v34.py @@ -1,7 +1,9 @@ +from typing import Any, Dict, Optional + import voluptuous as vol from solax.inverter import Inverter -from solax.units import Total, Units +from solax.units import DailyTotal, Measurement, Total, Units from solax.utils import div10, div100, pack_u16, to_signed, twoway_div10, twoway_div100 @@ -14,13 +16,13 @@ class X3V34(Inverter): vol.Required("type"): vol.All(int, 5), vol.Required("sn"): str, vol.Required("ver"): str, - vol.Required("Data"): vol.Schema( + vol.Required("data"): vol.Schema( vol.All( [vol.Coerce(float)], vol.Length(min=200, max=200), ) ), - vol.Required("Information"): vol.Schema( + vol.Required("information"): vol.Schema( vol.All(vol.Length(min=10, max=10)) ), }, @@ -46,12 +48,12 @@ def response_decoder(cls): "PV1 Power": (13, Units.W), "PV2 Power": (14, Units.W), "Total PV Energy": (pack_u16(89, 90), Total(Units.KWH), div10), - "Today's PV Energy": (112, Units.KWH, div10), + "Today's PV Energy": (112, DailyTotal(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": (pack_u16(19, 20), Total(Units.KWH), div10), - "Today's Energy": (21, Units.KWH, div10), + "Today's Energy": (21, DailyTotal(Units.KWH), div10), "Battery Voltage": (24, Units.V, div100), "Battery Current": (25, Units.A, twoway_div100), "Battery Power": (26, Units.W, to_signed), @@ -62,20 +64,32 @@ def response_decoder(cls): Total(Units.KWH), div10, ), - "Today's Battery Discharge Energy": (113, Units.KWH, div10), - "Battery Remaining Energy": (32, Units.KWH, div10), + "Today's Battery Discharge Energy": (113, DailyTotal(Units.KWH), div10), + "Battery Remaining Energy": ( + 32, + Measurement(Units.KWH, storage=True), + div10, + ), "Total Battery Charge Energy": ( pack_u16(87, 88), Total(Units.KWH), div10, ), - "Today's Battery Charge Energy": (114, Units.KWH, div10), + "Today's Battery Charge Energy": (114, DailyTotal(Units.KWH), div10), "Exported Power": (65, Units.W, to_signed), "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": (pack_u16(110, 111), Units.KWH, div10), + "EPS Total Energy": ( + pack_u16(110, 111), + Measurement(Units.KWH, storage=False), + div10, + ), } # pylint: enable=duplicate-code + + @classmethod + def inverter_serial_number_getter(cls, response: Dict[str, Any]) -> Optional[str]: + return response["information"][2] diff --git a/solax/inverters/x_hybrid.py b/solax/inverters/x_hybrid.py index f620c99..f2b3b55 100644 --- a/solax/inverters/x_hybrid.py +++ b/solax/inverters/x_hybrid.py @@ -1,7 +1,9 @@ +from typing import Any, Dict, Optional + import voluptuous as vol -from solax.inverter import Inverter, InverterHttpClient, Method, ResponseParser -from solax.units import Total, Units +from solax.inverter import Inverter, InverterHttpClient, Method +from solax.units import DailyTotal, Total, Units class XHybrid(Inverter): @@ -15,14 +17,14 @@ class XHybrid(Inverter): vol.Required("method"): str, vol.Required("version"): str, vol.Required("type"): str, - vol.Required("SN"): str, - vol.Required("Data"): vol.Schema( + vol.Required("sn"): str, + vol.Required("data"): vol.Schema( vol.All( [vol.Coerce(float)], vol.Any(vol.Length(min=58, max=58), vol.Length(min=68, max=68)), ) ), - vol.Required("Status"): vol.All(vol.Coerce(int), vol.Range(min=0)), + vol.Required("status"): vol.All(vol.Coerce(int), vol.Range(min=0)), }, extra=vol.REMOVE_EXTRA, ) @@ -31,9 +33,9 @@ class XHybrid(Inverter): def _build(cls, host, port, pwd="", params_in_query=True): base = "http://{}:{}/api/realTimeData.htm" url = base.format(host, port) - http_client = InverterHttpClient.build_w_url(url, Method.GET) - response_parser = ResponseParser(cls._schema, cls.response_decoder()) - return cls(http_client, response_parser) + http_client = InverterHttpClient(url=url, method=Method.GET, pwd="") + + return cls(http_client) @classmethod def build_all_variants(cls, host, port, pwd=""): @@ -55,7 +57,7 @@ def response_decoder(cls): "Network Voltage": (5, Units.V), "Power Now": (6, Units.W), "Inverter Temperature": (7, Units.C), - "Today's Energy": (8, Units.KWH), + "Today's Energy": (8, DailyTotal(Units.KWH)), "Total Energy": (9, Total(Units.KWH)), "Exported Power": (10, Units.W), "PV1 Power": (11, Units.W), @@ -74,3 +76,7 @@ def response_decoder(cls): "EPS Power": (55, Units.W), "EPS Frequency": (56, Units.HZ), } + + @classmethod + def inverter_serial_number_getter(cls, response: Dict[str, Any]) -> Optional[str]: + return None diff --git a/solax/response_parser.py b/solax/response_parser.py index 5d6cd85..027e236 100644 --- a/solax/response_parser.py +++ b/solax/response_parser.py @@ -1,7 +1,8 @@ import json import logging +import sys from collections import namedtuple -from typing import Any, Callable, Dict, Tuple, Union +from typing import Any, Callable, Dict, Generator, Optional, Tuple, Union import voluptuous as vol from voluptuous import Invalid, MultipleInvalid @@ -10,25 +11,54 @@ from solax.units import SensorUnit from solax.utils import PackerBuilderResult +__all__ = ("ResponseParser", "InverterResponse", "ResponseDecoder") + +if sys.version_info >= (3, 11): + from typing import Unpack +else: + from typing_extensions import Unpack + _LOGGER = logging.getLogger(__name__) _LOGGER.setLevel(logging.INFO) -InverterResponse = namedtuple("InverterResponse", "data, serial_number, version, type") +class InverterResponse( + namedtuple( + "InverterResponse", + [ + "data", + "dongle_serial_number", + "version", + "type", + "inverter_serial_number", + ], + ) +): + @property + def serial_number(self): + return self.dongle_serial_number + + +ProcessorTuple = Tuple[Callable[[Any], Any], ...] SensorIndexSpec = Union[int, PackerBuilderResult] ResponseDecoder = Dict[ str, - Union[ - Tuple[SensorIndexSpec, SensorUnit], - Tuple[SensorIndexSpec, SensorUnit, Callable[[Any], Any]], - ], + Tuple[SensorIndexSpec, SensorUnit, Unpack[ProcessorTuple]], ] class ResponseParser: - def __init__(self, schema: vol.Schema, decoder: ResponseDecoder): + def __init__( + self, + schema: vol.Schema, + decoder: ResponseDecoder, + dongle_serial_number_getter: Callable[[Dict[str, Any]], Optional[str]], + inverter_serial_number_getter: Callable[[Dict[str, Any]], Optional[str]], + ) -> None: self.schema = schema self.response_decoder = decoder + self.dongle_serial_number_getter = dongle_serial_number_getter + self.inverter_serial_number_getter = inverter_serial_number_getter def _decode_map(self) -> Dict[str, SensorIndexSpec]: sensors: Dict[str, SensorIndexSpec] = {} @@ -36,17 +66,16 @@ def _decode_map(self) -> Dict[str, SensorIndexSpec]: sensors[name] = mapping[0] return sensors - def _postprocess_map(self) -> Dict[str, Callable[[Any], Any]]: + def _postprocess_gen( + self, + ) -> Generator[Tuple[str, Callable[[Any], Any]], None, None]: """ Return map of functions to be applied to each sensor value """ - sensors: Dict[str, Callable[[Any], Any]] = {} for name, mapping in self.response_decoder.items(): - processor = None - (_, _, *processor) = mapping - if processor: - sensors[name] = processor[0] - return sensors + (_, _, *processors) = mapping + for processor in processors: + yield name, processor def map_response(self, resp_data) -> Dict[str, Any]: result = {} @@ -59,11 +88,11 @@ def map_response(self, resp_data) -> Dict[str, Any]: else: val = resp_data[decode_info] result[sensor_name] = val - for sensor_name, processor in self._postprocess_map().items(): + for sensor_name, processor in self._postprocess_gen(): result[sensor_name] = processor(result[sensor_name]) return result - def handle_response(self, resp: bytearray): + def handle_response(self, resp: bytearray) -> InverterResponse: """ Decode response and map array result using mapping definition. @@ -75,15 +104,20 @@ def handle_response(self, resp: bytearray): """ raw_json = resp.decode("utf-8").replace(",,", ",0.0,").replace(",,", ",0.0,") - json_response = json.loads(raw_json) + json_response = {} + for key, value in json.loads(raw_json).items(): + json_response[key.lower()] = value + try: response = self.schema(json_response) except (Invalid, MultipleInvalid) as ex: _ = humanize_error(json_response, ex) raise + return InverterResponse( - data=self.map_response(response["Data"]), - serial_number=response.get("SN", response.get("sn")), + data=self.map_response(response["data"]), + dongle_serial_number=self.dongle_serial_number_getter(response), version=response.get("ver", response.get("version")), type=response["type"], + inverter_serial_number=self.inverter_serial_number_getter(response), ) diff --git a/solax/units.py b/solax/units.py index f8ca1c3..eab9cbf 100644 --- a/solax/units.py +++ b/solax/units.py @@ -22,6 +22,8 @@ class Measurement(NamedTuple): unit: Units is_monotonic: bool = False + resets_daily: bool = False + storage: bool = False class Total(Measurement): @@ -30,4 +32,10 @@ class Total(Measurement): is_monotonic: bool = True +class DailyTotal(Measurement): + """A Measurement where the values are reset daily.""" + + resets_daily: bool = True + + SensorUnit = Union[Measurement, Total] diff --git a/tests/samples/responses.py b/tests/samples/responses.py index 64304a9..0e4078f 100644 --- a/tests/samples/responses.py +++ b/tests/samples/responses.py @@ -1517,7 +1517,7 @@ 3.000, 3, "X1-Hybiyd-G3", - "YYYYYYYYYYYYYY", + "XXXXXXXXXXXXXX", 1, 3.11, 0.00, @@ -1535,7 +1535,7 @@ X1_HYBRID_G4_RESPONSE = { "type": 15, - "sn": "SXxxxxxxxx", + "sn": "SXXXXXXXXX", "ver": "3.003.02", "Data": [ 2470, @@ -1742,7 +1742,7 @@ "Information": [ 5.000, 15, - "H450xxxxxxxxxx", + "H450XXXXXXXXXX", 8, 1.24, 0.00, @@ -2614,7 +2614,7 @@ } X3_HYBRID_G4_RESPONSE = { - "sn": "SR3xxxxxxx", + "sn": "SR3XXXXXXX", "ver": "3.006.04", "type": 14, "Data": [ @@ -2919,11 +2919,11 @@ 0, 0, ], - "Information": [10.000, 14, "H34A**********", 8, 1.23, 0.00, 1.24, 1.09, 0.00, 1], + "Information": [10.000, 14, "H34AXXXXXXXXXX", 8, 1.23, 0.00, 1.24, 1.09, 0.00, 1], } X3_MICPRO_G2_RESPONSE = { - "sn": "SRE*******", + "sn": "SREXXXXXXX", "ver": "3.008.10", "type": 16, "Data": [ @@ -3028,11 +3028,11 @@ 0, 0, ], - "Information": [4.000, 16, "MC20**********", 8, 1.20, 0.00, 1.18, 1.00, 0.00, 1], + "Information": [4.000, 16, "MC20XXXXXXXXXX", 8, 1.20, 0.00, 1.18, 1.00, 0.00, 1], } QVOLTHYBG33P_RESPONSE_V34 = { - "sn": "SWX***", + "sn": "SWXXXX", "ver": "2.034.06", "type": 14, "Data": [ @@ -3237,5 +3237,5 @@ 0, 0, ], - "Information": [12.0, 14, "H34***", 1, 1.15, 0.0, 1.14, 1.07, 0.0, 1], + "Information": [12.0, 14, "H34XXXXXXXX", 1, 1.15, 0.0, 1.14, 1.07, 0.0, 1], } diff --git a/tests/test_base_inverter.py b/tests/test_base_inverter.py index 3cab1a1..fa69619 100644 --- a/tests/test_base_inverter.py +++ b/tests/test_base_inverter.py @@ -5,6 +5,7 @@ def test_all_registered_inverters_inherit_from_base(): + assert REGISTRY for i in REGISTRY: assert issubclass(i, Inverter) @@ -12,4 +13,4 @@ def test_all_registered_inverters_inherit_from_base(): def test_unimplemented_response_decoder(): with pytest.raises(NotImplementedError): versions = Inverter.build_all_variants("localhost", 80) - versions[0].response_decoder() + next(iter(versions)).response_decoder() diff --git a/tests/test_discovery.py b/tests/test_discovery.py index 2f57b50..3a2f956 100644 --- a/tests/test_discovery.py +++ b/tests/test_discovery.py @@ -1,14 +1,119 @@ +import asyncio + import pytest import solax -from solax.discovery import DiscoveryError +from solax import InverterResponse +from solax.discovery import REGISTRY, DiscoveryError +from solax.inverter import InverterError +from solax.inverters import X1Boost + + +class DelayedX1Boost(X1Boost): + async def get_data(self) -> InverterResponse: + await asyncio.sleep(10) + return await super().get_data() + + +class DelayedFailedX1Boost(X1Boost): + async def make_request(self) -> InverterResponse: + await asyncio.sleep(5) + raise InverterError @pytest.mark.asyncio async def test_discovery(inverters_fixture): conn, inverter_class, _ = inverters_fixture + inverters = await solax.discover(*conn, return_when=asyncio.ALL_COMPLETED) + assert inverter_class in {type(inverter) for inverter in inverters} + + for inverter in inverters: + if isinstance(inverter, inverter_class): + data = await inverter.get_data() + assert "X" * 7 in (data.inverter_serial_number or "X" * 7) + assert data.serial_number == data.dongle_serial_number + + +@pytest.mark.asyncio +async def test_real_time_api(inverters_fixture): + conn, inverter_class, _ = inverters_fixture + + if inverter_class is not X1Boost: + pytest.skip() + rt_api = await solax.real_time_api(*conn) - assert rt_api.inverter.__class__ == inverter_class + assert rt_api.inverter.__class__ is inverter_class + + +@pytest.mark.asyncio +async def test_discovery_cancelled_error_while_staggering( + inverters_fixture, +): + conn, inverter_class, _ = inverters_fixture + + if inverter_class is not X1Boost: + pytest.skip() + + task = asyncio.create_task( + solax.discover(*conn, return_when=asyncio.FIRST_EXCEPTION) + ) + await asyncio.sleep(1) + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + + +@pytest.mark.asyncio +async def test_discovery_cancelled_error_after_staggering( + inverters_fixture, +): + conn, inverter_class, _ = inverters_fixture + + if inverter_class is not X1Boost: + pytest.skip() + + inverters = set(REGISTRY) + inverters.add(DelayedX1Boost) + + task = asyncio.create_task( + solax.discover(*conn, inverters=inverters, return_when=asyncio.FIRST_EXCEPTION) + ) + await asyncio.sleep(7) + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + + +@pytest.mark.asyncio +async def test_discovery_first_completed_after_staggering( + inverters_fixture, +): + conn, inverter_class, _ = inverters_fixture + + if inverter_class is not X1Boost: + pytest.skip() + + inverter = await solax.discover( + *conn, inverters=[DelayedX1Boost], return_when=asyncio.FIRST_COMPLETED + ) + assert inverter.__class__ is DelayedX1Boost + + +@pytest.mark.asyncio +async def test_discovery_not_first_completed_after_staggering( + inverters_fixture, +): + conn, inverter_class, _ = inverters_fixture + + if inverter_class is not X1Boost: + pytest.skip() + + inverters = await solax.discover( + *conn, + inverters=[DelayedX1Boost, DelayedFailedX1Boost], + return_when=asyncio.FIRST_EXCEPTION + ) + assert DelayedX1Boost in {type(inverter) for inverter in inverters} @pytest.mark.asyncio @@ -27,3 +132,9 @@ async def test_discovery_no_host_with_pwd(): async def test_discovery_unknown_webserver(simple_http_fixture): with pytest.raises(DiscoveryError): await solax.real_time_api(*simple_http_fixture) + + +@pytest.mark.asyncio +async def test_discovery_empty_inverter_class_iterable(): + with pytest.raises(DiscoveryError): + await solax.discover("localhost", 2, inverters=[])