Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Refactor discovery #145

Merged
merged 4 commits into from
Mar 31, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
33 changes: 30 additions & 3 deletions .github/workflows/tests.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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: |
Expand All @@ -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
18 changes: 18 additions & 0 deletions setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
],
},
)
2 changes: 1 addition & 1 deletion solax/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)


Expand Down
232 changes: 145 additions & 87 deletions solax/discovery.py
Original file line number Diff line number Diff line change
@@ -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()
Loading